Spaces:
Running on Zero
Running on Zero
feat(report): add annotated issue clips
Browse files- app.py +91 -0
- src/pozify/contracts.py +31 -0
- src/pozify/exercises/shared/issue_marker.py +1 -0
- src/pozify/pipeline.py +13 -3
- src/pozify/steps/annotated_renderer.py +364 -15
- tests/test_annotated_renderer.py +120 -4
- tests/test_issue_marker.py +3 -0
- tests/test_pipeline_contracts.py +5 -0
- web/app.js +436 -70
- web/styles.css +111 -1
app.py
CHANGED
|
@@ -76,6 +76,9 @@ def _artifact_url(run_id: str, path: str | None) -> str | None:
|
|
| 76 |
if not path:
|
| 77 |
return None
|
| 78 |
|
|
|
|
|
|
|
|
|
|
| 79 |
artifact_path = Path(path).resolve()
|
| 80 |
run_root = (RUNS_ROOT / run_id).resolve()
|
| 81 |
if run_root not in artifact_path.parents or not artifact_path.is_file():
|
|
@@ -83,6 +86,69 @@ def _artifact_url(run_id: str, path: str | None) -> str | None:
|
|
| 83 |
return f"/api/artifacts/{run_id}/{artifact_path.name}"
|
| 84 |
|
| 85 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
@server.post("/api/analyze")
|
| 87 |
async def analyze_api(
|
| 88 |
video: UploadFile | None = File(default=None),
|
|
@@ -130,12 +196,37 @@ async def analyze_api(
|
|
| 130 |
if video_path is not None:
|
| 131 |
Path(video_path).unlink(missing_ok=True)
|
| 132 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
}
|
|
|
|
| 76 |
if not path:
|
| 77 |
return None
|
| 78 |
|
| 79 |
+
if not isinstance(path, str):
|
| 80 |
+
return None
|
| 81 |
+
|
| 82 |
artifact_path = Path(path).resolve()
|
| 83 |
run_root = (RUNS_ROOT / run_id).resolve()
|
| 84 |
if run_root not in artifact_path.parents or not artifact_path.is_file():
|
|
|
|
| 86 |
return f"/api/artifacts/{run_id}/{artifact_path.name}"
|
| 87 |
|
| 88 |
|
| 89 |
+
def _artifact_link(run_id: str, name: str, path: str | None) -> dict[str, str] | None:
|
| 90 |
+
url = _artifact_url(run_id, path)
|
| 91 |
+
if url is None:
|
| 92 |
+
return None
|
| 93 |
+
return {"name": name, "url": url}
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def _artifact_urls(result: dict[str, Any]) -> list[dict[str, str]]:
|
| 97 |
+
run_id = result["run_id"]
|
| 98 |
+
run_dir = Path(str(result["run_dir"]))
|
| 99 |
+
links: list[dict[str, str]] = []
|
| 100 |
+
artifact_files = [
|
| 101 |
+
"final_report.json",
|
| 102 |
+
"video_manifest.json",
|
| 103 |
+
"pose_sequence.json",
|
| 104 |
+
"reps.json",
|
| 105 |
+
"rep_analysis.json",
|
| 106 |
+
"variation.json",
|
| 107 |
+
"issue_markers.json",
|
| 108 |
+
"coach_summary.json",
|
| 109 |
+
"verification.json",
|
| 110 |
+
"manifest.json",
|
| 111 |
+
]
|
| 112 |
+
for filename in artifact_files:
|
| 113 |
+
link = _artifact_link(run_id, filename, str(run_dir / filename))
|
| 114 |
+
if link is not None:
|
| 115 |
+
links.append(link)
|
| 116 |
+
|
| 117 |
+
video_link = _artifact_link(
|
| 118 |
+
run_id,
|
| 119 |
+
"annotated_video.mp4",
|
| 120 |
+
result.get("annotated_video_path"),
|
| 121 |
+
)
|
| 122 |
+
if video_link is not None:
|
| 123 |
+
links.append(video_link)
|
| 124 |
+
|
| 125 |
+
for thumbnail in result.get("issue_thumbnail_paths", []):
|
| 126 |
+
if not isinstance(thumbnail, dict):
|
| 127 |
+
continue
|
| 128 |
+
path = thumbnail.get("path")
|
| 129 |
+
issue = thumbnail.get("issue", "issue")
|
| 130 |
+
rep_id = thumbnail.get("rep_id", "?")
|
| 131 |
+
if not isinstance(path, str):
|
| 132 |
+
continue
|
| 133 |
+
link = _artifact_link(run_id, f"thumbnail_rep_{rep_id}_{issue}.jpg", path)
|
| 134 |
+
if link is not None:
|
| 135 |
+
links.append(link)
|
| 136 |
+
|
| 137 |
+
for clip in result.get("issue_clip_paths", []):
|
| 138 |
+
if not isinstance(clip, dict):
|
| 139 |
+
continue
|
| 140 |
+
path = clip.get("path")
|
| 141 |
+
issue = clip.get("issue", "issue")
|
| 142 |
+
rep_id = clip.get("rep_id", "?")
|
| 143 |
+
if not isinstance(path, str):
|
| 144 |
+
continue
|
| 145 |
+
link = _artifact_link(run_id, f"clip_rep_{rep_id}_{issue}.mp4", path)
|
| 146 |
+
if link is not None:
|
| 147 |
+
links.append(link)
|
| 148 |
+
|
| 149 |
+
return links
|
| 150 |
+
|
| 151 |
+
|
| 152 |
@server.post("/api/analyze")
|
| 153 |
async def analyze_api(
|
| 154 |
video: UploadFile | None = File(default=None),
|
|
|
|
| 196 |
if video_path is not None:
|
| 197 |
Path(video_path).unlink(missing_ok=True)
|
| 198 |
|
| 199 |
+
issue_thumbnail_urls = []
|
| 200 |
+
for thumbnail in result.get("issue_thumbnail_paths", []):
|
| 201 |
+
if not isinstance(thumbnail, dict):
|
| 202 |
+
continue
|
| 203 |
+
path = thumbnail.get("path")
|
| 204 |
+
if not isinstance(path, str):
|
| 205 |
+
continue
|
| 206 |
+
url = _artifact_url(result["run_id"], path)
|
| 207 |
+
if url is not None:
|
| 208 |
+
issue_thumbnail_urls.append({**thumbnail, "url": url})
|
| 209 |
+
|
| 210 |
+
issue_clip_urls = []
|
| 211 |
+
for clip in result.get("issue_clip_paths", []):
|
| 212 |
+
if not isinstance(clip, dict):
|
| 213 |
+
continue
|
| 214 |
+
path = clip.get("path")
|
| 215 |
+
if not isinstance(path, str):
|
| 216 |
+
continue
|
| 217 |
+
url = _artifact_url(result["run_id"], path)
|
| 218 |
+
if url is not None:
|
| 219 |
+
issue_clip_urls.append({**clip, "url": url})
|
| 220 |
+
|
| 221 |
return {
|
| 222 |
"run_id": result["run_id"],
|
| 223 |
"run_dir": result["run_dir"],
|
| 224 |
"annotated_video_url": _artifact_url(
|
| 225 |
result["run_id"], result["annotated_video_path"]
|
| 226 |
),
|
| 227 |
+
"issue_thumbnail_urls": issue_thumbnail_urls,
|
| 228 |
+
"issue_clip_urls": issue_clip_urls,
|
| 229 |
+
"artifact_urls": _artifact_urls(result),
|
| 230 |
"final_report_url": f"/api/artifacts/{result['run_id']}/final_report.json",
|
| 231 |
"report": result["final_report"],
|
| 232 |
}
|
src/pozify/contracts.py
CHANGED
|
@@ -557,6 +557,37 @@ def _validate_final_report(value: Any, path: str) -> None:
|
|
| 557 |
_require_type(artifacts["run_dir"], str, f"{path}.artifacts.run_dir")
|
| 558 |
if artifacts["annotated_video_path"] is not None:
|
| 559 |
_require_type(artifacts["annotated_video_path"], str, f"{path}.artifacts.annotated_video_path")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 560 |
|
| 561 |
|
| 562 |
def _validate_run_manifest(value: Any, path: str) -> None:
|
|
|
|
| 557 |
_require_type(artifacts["run_dir"], str, f"{path}.artifacts.run_dir")
|
| 558 |
if artifacts["annotated_video_path"] is not None:
|
| 559 |
_require_type(artifacts["annotated_video_path"], str, f"{path}.artifacts.annotated_video_path")
|
| 560 |
+
if "issue_thumbnail_paths" in artifacts:
|
| 561 |
+
_require_type(artifacts["issue_thumbnail_paths"], list, f"{path}.artifacts.issue_thumbnail_paths")
|
| 562 |
+
for index, thumbnail_value in enumerate(artifacts["issue_thumbnail_paths"]):
|
| 563 |
+
thumbnail_path = f"{path}.artifacts.issue_thumbnail_paths[{index}]"
|
| 564 |
+
thumbnail = _require_mapping(thumbnail_value, thumbnail_path)
|
| 565 |
+
_require_fields(thumbnail, {"issue", "rep_id", "frame", "path"}, thumbnail_path)
|
| 566 |
+
_require_type(thumbnail["issue"], str, f"{thumbnail_path}.issue")
|
| 567 |
+
_require_int(thumbnail["rep_id"], f"{thumbnail_path}.rep_id", minimum=1)
|
| 568 |
+
_require_int(thumbnail["frame"], f"{thumbnail_path}.frame", minimum=0)
|
| 569 |
+
_require_type(thumbnail["path"], str, f"{thumbnail_path}.path")
|
| 570 |
+
if "issue_clip_paths" in artifacts:
|
| 571 |
+
_require_type(artifacts["issue_clip_paths"], list, f"{path}.artifacts.issue_clip_paths")
|
| 572 |
+
for index, clip_value in enumerate(artifacts["issue_clip_paths"]):
|
| 573 |
+
clip_path = f"{path}.artifacts.issue_clip_paths[{index}]"
|
| 574 |
+
clip = _require_mapping(clip_value, clip_path)
|
| 575 |
+
_require_fields(
|
| 576 |
+
clip,
|
| 577 |
+
{"issue", "rep_id", "start_sec", "end_sec", "clip_start_sec", "clip_end_sec", "path"},
|
| 578 |
+
clip_path,
|
| 579 |
+
)
|
| 580 |
+
_require_type(clip["issue"], str, f"{clip_path}.issue")
|
| 581 |
+
_require_int(clip["rep_id"], f"{clip_path}.rep_id", minimum=1)
|
| 582 |
+
_require_number(clip["start_sec"], f"{clip_path}.start_sec", minimum=0)
|
| 583 |
+
_require_number(clip["end_sec"], f"{clip_path}.end_sec", minimum=0)
|
| 584 |
+
_require_number(clip["clip_start_sec"], f"{clip_path}.clip_start_sec", minimum=0)
|
| 585 |
+
_require_number(clip["clip_end_sec"], f"{clip_path}.clip_end_sec", minimum=0)
|
| 586 |
+
if clip["start_sec"] > clip["end_sec"]:
|
| 587 |
+
raise ContractValidationError(f"{clip_path} timestamps must be ordered")
|
| 588 |
+
if clip["clip_start_sec"] > clip["clip_end_sec"]:
|
| 589 |
+
raise ContractValidationError(f"{clip_path} clip timestamps must be ordered")
|
| 590 |
+
_require_type(clip["path"], str, f"{clip_path}.path")
|
| 591 |
|
| 592 |
|
| 593 |
def _validate_run_manifest(value: Any, path: str) -> None:
|
src/pozify/exercises/shared/issue_marker.py
CHANGED
|
@@ -149,6 +149,7 @@ def marker_from_group(
|
|
| 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,
|
|
|
|
| 149 |
rule.metric_name: round(peak_score.value, 4),
|
| 150 |
"threshold": rule.threshold,
|
| 151 |
"confidence": confidence(group, rep_item, variation),
|
| 152 |
+
"peak_frame": peak_score.frame.frame_index,
|
| 153 |
"variation_context": {
|
| 154 |
"detected_variation": variation.detected_variation,
|
| 155 |
"variation_confidence": variation.variation_confidence,
|
src/pozify/pipeline.py
CHANGED
|
@@ -94,7 +94,13 @@ def run_pipeline(
|
|
| 94 |
issues = exercise.mark_issues(reps, analysis, variation)
|
| 95 |
write_artifact("issue_markers.json", issues)
|
| 96 |
|
| 97 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
|
| 99 |
pose_source = (
|
| 100 |
cleaned_pose_sequence.frames[0].pose_quality.get("source")
|
|
@@ -127,7 +133,9 @@ def run_pipeline(
|
|
| 127 |
"verification": to_dict(verification),
|
| 128 |
"artifacts": {
|
| 129 |
"run_dir": str(run_dir),
|
| 130 |
-
"annotated_video_path": annotated_video_path,
|
|
|
|
|
|
|
| 131 |
"rep_debug_path": str(run_dir / "rep_debug.json"),
|
| 132 |
"analysis_mode": analysis_mode,
|
| 133 |
"pose_source": pose_source,
|
|
@@ -146,7 +154,9 @@ def run_pipeline(
|
|
| 146 |
return {
|
| 147 |
"run_id": run_id,
|
| 148 |
"run_dir": str(run_dir),
|
| 149 |
-
"annotated_video_path": annotated_video_path,
|
|
|
|
|
|
|
| 150 |
"manifest_path": str(run_dir / "manifest.json"),
|
| 151 |
"final_report": final_report,
|
| 152 |
}
|
|
|
|
| 94 |
issues = exercise.mark_issues(reps, analysis, variation)
|
| 95 |
write_artifact("issue_markers.json", issues)
|
| 96 |
|
| 97 |
+
render_artifacts = annotated_renderer.run(
|
| 98 |
+
manifest,
|
| 99 |
+
cleaned_pose_sequence,
|
| 100 |
+
reps,
|
| 101 |
+
issues,
|
| 102 |
+
run_dir,
|
| 103 |
+
)
|
| 104 |
|
| 105 |
pose_source = (
|
| 106 |
cleaned_pose_sequence.frames[0].pose_quality.get("source")
|
|
|
|
| 133 |
"verification": to_dict(verification),
|
| 134 |
"artifacts": {
|
| 135 |
"run_dir": str(run_dir),
|
| 136 |
+
"annotated_video_path": render_artifacts.annotated_video_path,
|
| 137 |
+
"issue_thumbnail_paths": render_artifacts.issue_thumbnail_paths,
|
| 138 |
+
"issue_clip_paths": render_artifacts.issue_clip_paths,
|
| 139 |
"rep_debug_path": str(run_dir / "rep_debug.json"),
|
| 140 |
"analysis_mode": analysis_mode,
|
| 141 |
"pose_source": pose_source,
|
|
|
|
| 154 |
return {
|
| 155 |
"run_id": run_id,
|
| 156 |
"run_dir": str(run_dir),
|
| 157 |
+
"annotated_video_path": render_artifacts.annotated_video_path,
|
| 158 |
+
"issue_thumbnail_paths": render_artifacts.issue_thumbnail_paths,
|
| 159 |
+
"issue_clip_paths": render_artifacts.issue_clip_paths,
|
| 160 |
"manifest_path": str(run_dir / "manifest.json"),
|
| 161 |
"final_report": final_report,
|
| 162 |
}
|
src/pozify/steps/annotated_renderer.py
CHANGED
|
@@ -1,14 +1,16 @@
|
|
| 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
|
| 10 |
|
| 11 |
-
from pozify.contracts import IssueMarkers, PoseSequence, Reps, VideoManifest
|
| 12 |
|
| 13 |
|
| 14 |
SKELETON_EDGES = [
|
|
@@ -38,6 +40,18 @@ BT709_COLOR_ARGS = (
|
|
| 38 |
"bt709",
|
| 39 |
)
|
| 40 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
|
| 42 |
def _tool_path(name: str) -> str | None:
|
| 43 |
return shutil.which(name)
|
|
@@ -224,16 +238,78 @@ def _frame_landmark_points(
|
|
| 224 |
return points
|
| 225 |
|
| 226 |
|
| 227 |
-
def _draw_pose(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 228 |
for start_name, end_name in SKELETON_EDGES:
|
| 229 |
start = points.get(start_name)
|
| 230 |
end = points.get(end_name)
|
| 231 |
if start is None or end is None:
|
| 232 |
continue
|
| 233 |
-
cv2.line(frame, start, end,
|
| 234 |
|
| 235 |
for point in points.values():
|
| 236 |
-
cv2.circle(frame, point, 3,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 237 |
|
| 238 |
|
| 239 |
def _rep_boundaries(reps: Reps) -> dict[int, list[str]]:
|
|
@@ -245,12 +321,225 @@ def _rep_boundaries(reps: Reps) -> dict[int, list[str]]:
|
|
| 245 |
return boundaries
|
| 246 |
|
| 247 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 248 |
def _draw_overlays(
|
| 249 |
frame: Any,
|
| 250 |
frame_index: int,
|
| 251 |
rep_count: int,
|
| 252 |
issue_count: int,
|
| 253 |
boundary_labels: dict[int, list[str]],
|
|
|
|
|
|
|
|
|
|
| 254 |
) -> None:
|
| 255 |
cv2.putText(
|
| 256 |
frame,
|
|
@@ -272,15 +561,31 @@ def _draw_overlays(
|
|
| 272 |
2,
|
| 273 |
cv2.LINE_AA,
|
| 274 |
)
|
| 275 |
-
labels = boundary_labels.get(frame_index, [])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 276 |
for offset, label in enumerate(labels):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 277 |
cv2.putText(
|
| 278 |
frame,
|
| 279 |
label,
|
| 280 |
(16, 96 + offset * 28),
|
| 281 |
cv2.FONT_HERSHEY_SIMPLEX,
|
| 282 |
0.75,
|
| 283 |
-
|
| 284 |
2,
|
| 285 |
cv2.LINE_AA,
|
| 286 |
)
|
|
@@ -311,10 +616,15 @@ def run(
|
|
| 311 |
reps: Reps,
|
| 312 |
issues: IssueMarkers,
|
| 313 |
run_dir: Path,
|
| 314 |
-
) ->
|
| 315 |
if not manifest.analysis_allowed or not manifest.video_path:
|
| 316 |
-
return
|
|
|
|
|
|
|
|
|
|
|
|
|
| 317 |
|
|
|
|
| 318 |
source_path = Path(manifest.video_path)
|
| 319 |
color_metadata = _video_color_metadata(manifest.video_path)
|
| 320 |
render_input_path = source_path
|
|
@@ -330,14 +640,22 @@ def run(
|
|
| 330 |
capture.release()
|
| 331 |
for temporary_path in temporary_paths:
|
| 332 |
temporary_path.unlink(missing_ok=True)
|
| 333 |
-
return
|
|
|
|
|
|
|
|
|
|
|
|
|
| 334 |
|
| 335 |
fps = manifest.fps if manifest.fps > 0 else 30.0
|
| 336 |
width = manifest.width or int(capture.get(cv2.CAP_PROP_FRAME_WIDTH) or 0)
|
| 337 |
height = manifest.height or int(capture.get(cv2.CAP_PROP_FRAME_HEIGHT) or 0)
|
| 338 |
if width <= 0 or height <= 0:
|
| 339 |
capture.release()
|
| 340 |
-
return
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
@@ -346,13 +664,19 @@ def run(
|
|
| 346 |
capture.release()
|
| 347 |
for temporary_path in temporary_paths:
|
| 348 |
temporary_path.unlink(missing_ok=True)
|
| 349 |
-
return
|
|
|
|
|
|
|
|
|
|
|
|
|
| 350 |
|
| 351 |
pose_by_frame = {frame.frame_index: frame for frame in pose_sequence.frames}
|
| 352 |
ordered_pose_frames = sorted(pose_sequence.frames, key=lambda frame: frame.frame_index)
|
| 353 |
pose_cursor = 0
|
| 354 |
last_pose_frame = None
|
| 355 |
boundary_labels = _rep_boundaries(reps)
|
|
|
|
|
|
|
| 356 |
|
| 357 |
try:
|
| 358 |
frame_index = 0
|
|
@@ -373,11 +697,25 @@ def run(
|
|
| 373 |
|
| 374 |
exact_pose = pose_by_frame.get(frame_index)
|
| 375 |
active_pose = exact_pose or last_pose_frame
|
|
|
|
| 376 |
if active_pose is not None and active_pose.landmarks:
|
| 377 |
points = _frame_landmark_points(active_pose.landmarks, width, height)
|
| 378 |
-
_draw_pose(frame, points)
|
| 379 |
-
|
| 380 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 381 |
writer.write(frame)
|
| 382 |
frame_index += 1
|
| 383 |
finally:
|
|
@@ -392,4 +730,15 @@ def run(
|
|
| 392 |
raw_output_path.replace(output_path)
|
| 393 |
raw_output_path.unlink(missing_ok=True)
|
| 394 |
|
| 395 |
-
return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
+
from dataclasses import dataclass
|
| 4 |
import json
|
| 5 |
from pathlib import Path
|
| 6 |
+
import re
|
| 7 |
import shutil
|
| 8 |
import subprocess
|
| 9 |
from typing import Any
|
| 10 |
|
| 11 |
import cv2
|
| 12 |
|
| 13 |
+
from pozify.contracts import IssueMarker, IssueMarkers, PoseSequence, Reps, VideoManifest
|
| 14 |
|
| 15 |
|
| 16 |
SKELETON_EDGES = [
|
|
|
|
| 40 |
"bt709",
|
| 41 |
)
|
| 42 |
|
| 43 |
+
NORMAL_EDGE_COLOR = (90, 220, 90)
|
| 44 |
+
NORMAL_JOINT_COLOR = (255, 240, 40)
|
| 45 |
+
ISSUE_EDGE_COLOR = (0, 130, 255)
|
| 46 |
+
ISSUE_JOINT_COLOR = (0, 40, 255)
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
@dataclass(frozen=True)
|
| 50 |
+
class RenderArtifacts:
|
| 51 |
+
annotated_video_path: str | None
|
| 52 |
+
issue_thumbnail_paths: list[dict[str, Any]]
|
| 53 |
+
issue_clip_paths: list[dict[str, Any]]
|
| 54 |
+
|
| 55 |
|
| 56 |
def _tool_path(name: str) -> str | None:
|
| 57 |
return shutil.which(name)
|
|
|
|
| 238 |
return points
|
| 239 |
|
| 240 |
|
| 241 |
+
def _draw_pose(
|
| 242 |
+
frame: Any,
|
| 243 |
+
points: dict[str, tuple[int, int]],
|
| 244 |
+
highlighted_joints: set[str] | None = None,
|
| 245 |
+
) -> None:
|
| 246 |
+
highlighted_joints = highlighted_joints or set()
|
| 247 |
for start_name, end_name in SKELETON_EDGES:
|
| 248 |
start = points.get(start_name)
|
| 249 |
end = points.get(end_name)
|
| 250 |
if start is None or end is None:
|
| 251 |
continue
|
| 252 |
+
cv2.line(frame, start, end, NORMAL_EDGE_COLOR, 2)
|
| 253 |
|
| 254 |
for point in points.values():
|
| 255 |
+
cv2.circle(frame, point, 3, NORMAL_JOINT_COLOR, -1)
|
| 256 |
+
|
| 257 |
+
if not highlighted_joints:
|
| 258 |
+
return
|
| 259 |
+
|
| 260 |
+
for start_name, end_name in SKELETON_EDGES:
|
| 261 |
+
if start_name not in highlighted_joints and end_name not in highlighted_joints:
|
| 262 |
+
continue
|
| 263 |
+
start = points.get(start_name)
|
| 264 |
+
end = points.get(end_name)
|
| 265 |
+
if start is None or end is None:
|
| 266 |
+
continue
|
| 267 |
+
cv2.line(frame, start, end, ISSUE_EDGE_COLOR, 4)
|
| 268 |
+
|
| 269 |
+
for name in highlighted_joints:
|
| 270 |
+
point = points.get(name)
|
| 271 |
+
if point is not None:
|
| 272 |
+
cv2.circle(frame, point, 6, ISSUE_JOINT_COLOR, -1)
|
| 273 |
+
|
| 274 |
+
|
| 275 |
+
def _issue_angle_label(issue: IssueMarker) -> str | None:
|
| 276 |
+
for key, value in issue.evidence.items():
|
| 277 |
+
if not key.endswith("_deg") or isinstance(value, bool) or not isinstance(value, int | float):
|
| 278 |
+
continue
|
| 279 |
+
return f"{key.removesuffix('_deg').replace('_', ' ')} {round(float(value))} deg"
|
| 280 |
+
return None
|
| 281 |
+
|
| 282 |
+
|
| 283 |
+
def _issue_label_anchor(issue: IssueMarker, points: dict[str, tuple[int, int]]) -> tuple[int, int]:
|
| 284 |
+
anchors = [points[name] for name in issue.affected_joints if name in points]
|
| 285 |
+
if not anchors:
|
| 286 |
+
return 16, 132
|
| 287 |
+
x = round(sum(point[0] for point in anchors) / len(anchors))
|
| 288 |
+
y = round(sum(point[1] for point in anchors) / len(anchors))
|
| 289 |
+
return x + 10, max(24, y - 10)
|
| 290 |
+
|
| 291 |
+
|
| 292 |
+
def _draw_angle_labels(
|
| 293 |
+
frame: Any,
|
| 294 |
+
points: dict[str, tuple[int, int]],
|
| 295 |
+
active_issues: list[IssueMarker],
|
| 296 |
+
) -> None:
|
| 297 |
+
for offset, issue in enumerate(active_issues):
|
| 298 |
+
label = _issue_angle_label(issue)
|
| 299 |
+
if label is None:
|
| 300 |
+
continue
|
| 301 |
+
anchor = _issue_label_anchor(issue, points)
|
| 302 |
+
text_anchor = (anchor[0], anchor[1] + offset * 28)
|
| 303 |
+
cv2.putText(
|
| 304 |
+
frame,
|
| 305 |
+
label,
|
| 306 |
+
text_anchor,
|
| 307 |
+
cv2.FONT_HERSHEY_SIMPLEX,
|
| 308 |
+
0.72,
|
| 309 |
+
ISSUE_EDGE_COLOR,
|
| 310 |
+
2,
|
| 311 |
+
cv2.LINE_AA,
|
| 312 |
+
)
|
| 313 |
|
| 314 |
|
| 315 |
def _rep_boundaries(reps: Reps) -> dict[int, list[str]]:
|
|
|
|
| 321 |
return boundaries
|
| 322 |
|
| 323 |
|
| 324 |
+
def _rep_phase(frame_index: int, reps: Reps) -> str | None:
|
| 325 |
+
for rep in reps.reps:
|
| 326 |
+
if not rep.start_frame <= frame_index <= rep.end_frame:
|
| 327 |
+
continue
|
| 328 |
+
if frame_index == rep.start_frame:
|
| 329 |
+
phase = "start"
|
| 330 |
+
elif frame_index == rep.mid_frame:
|
| 331 |
+
phase = "mid"
|
| 332 |
+
elif frame_index == rep.end_frame:
|
| 333 |
+
phase = "end"
|
| 334 |
+
elif frame_index < rep.mid_frame:
|
| 335 |
+
phase = "lowering"
|
| 336 |
+
else:
|
| 337 |
+
phase = "rising"
|
| 338 |
+
return f"rep {rep.rep_id} {phase}"
|
| 339 |
+
return None
|
| 340 |
+
|
| 341 |
+
|
| 342 |
+
def _active_issues(frame_index: int, issues: IssueMarkers) -> list[IssueMarker]:
|
| 343 |
+
return [
|
| 344 |
+
issue
|
| 345 |
+
for issue in issues.issues
|
| 346 |
+
if issue.start_frame <= frame_index <= issue.end_frame
|
| 347 |
+
]
|
| 348 |
+
|
| 349 |
+
|
| 350 |
+
def _highlighted_joints(active_issues: list[IssueMarker]) -> set[str]:
|
| 351 |
+
joints: set[str] = set()
|
| 352 |
+
for issue in active_issues:
|
| 353 |
+
joints.update(issue.affected_joints)
|
| 354 |
+
return joints
|
| 355 |
+
|
| 356 |
+
|
| 357 |
+
def _primary_issue(active_issues: list[IssueMarker]) -> IssueMarker | None:
|
| 358 |
+
if not active_issues:
|
| 359 |
+
return None
|
| 360 |
+
return max(active_issues, key=lambda issue: issue.severity)
|
| 361 |
+
|
| 362 |
+
|
| 363 |
+
def _confidence_warning(active_issues: list[IssueMarker], warnings: list[str]) -> str | None:
|
| 364 |
+
confidences = [
|
| 365 |
+
float(issue.evidence["confidence"])
|
| 366 |
+
for issue in active_issues
|
| 367 |
+
if isinstance(issue.evidence.get("confidence"), int | float)
|
| 368 |
+
]
|
| 369 |
+
if confidences and min(confidences) < 0.55:
|
| 370 |
+
return "Low confidence issue evidence"
|
| 371 |
+
if warnings:
|
| 372 |
+
return "Camera warning: " + ", ".join(warnings[:2])
|
| 373 |
+
return None
|
| 374 |
+
|
| 375 |
+
|
| 376 |
+
def _thumbnail_frame(issue: IssueMarker) -> int:
|
| 377 |
+
peak_frame = issue.evidence.get("peak_frame")
|
| 378 |
+
if isinstance(peak_frame, int) and peak_frame >= 0:
|
| 379 |
+
return peak_frame
|
| 380 |
+
return round((issue.start_frame + issue.end_frame) / 2)
|
| 381 |
+
|
| 382 |
+
|
| 383 |
+
def _slug(value: str) -> str:
|
| 384 |
+
slug = re.sub(r"[^a-zA-Z0-9]+", "_", value).strip("_").lower()
|
| 385 |
+
return slug or "issue"
|
| 386 |
+
|
| 387 |
+
|
| 388 |
+
def _thumbnail_targets(issues: IssueMarkers, run_dir: Path) -> dict[int, list[dict[str, Any]]]:
|
| 389 |
+
targets: dict[int, list[dict[str, Any]]] = {}
|
| 390 |
+
for index, issue in enumerate(issues.issues, start=1):
|
| 391 |
+
frame = _thumbnail_frame(issue)
|
| 392 |
+
filename = f"issue_thumbnail_{index}_{_slug(issue.issue)}.jpg"
|
| 393 |
+
targets.setdefault(frame, []).append(
|
| 394 |
+
{
|
| 395 |
+
"issue": issue.issue,
|
| 396 |
+
"rep_id": issue.rep_id,
|
| 397 |
+
"frame": frame,
|
| 398 |
+
"path": str(run_dir / filename),
|
| 399 |
+
}
|
| 400 |
+
)
|
| 401 |
+
return targets
|
| 402 |
+
|
| 403 |
+
|
| 404 |
+
def _clip_metadata(issue: IssueMarker, index: int, run_dir: Path) -> dict[str, Any]:
|
| 405 |
+
filename = f"issue_clip_{index}_{_slug(issue.issue)}.mp4"
|
| 406 |
+
clip_start_sec = max(0.0, float(issue.start_sec) - 1.0)
|
| 407 |
+
clip_end_sec = max(clip_start_sec + 0.1, float(issue.end_sec) + 1.0)
|
| 408 |
+
return {
|
| 409 |
+
"issue": issue.issue,
|
| 410 |
+
"rep_id": issue.rep_id,
|
| 411 |
+
"start_sec": issue.start_sec,
|
| 412 |
+
"end_sec": issue.end_sec,
|
| 413 |
+
"clip_start_sec": round(clip_start_sec, 3),
|
| 414 |
+
"clip_end_sec": round(clip_end_sec, 3),
|
| 415 |
+
"path": str(run_dir / filename),
|
| 416 |
+
}
|
| 417 |
+
|
| 418 |
+
|
| 419 |
+
def _issue_clip_paths(
|
| 420 |
+
source_path: Path,
|
| 421 |
+
issues: IssueMarkers,
|
| 422 |
+
run_dir: Path,
|
| 423 |
+
fps: float,
|
| 424 |
+
width: int,
|
| 425 |
+
height: int,
|
| 426 |
+
) -> list[dict[str, Any]]:
|
| 427 |
+
clips: list[dict[str, Any]] = []
|
| 428 |
+
for index, issue in enumerate(issues.issues, start=1):
|
| 429 |
+
clip = _clip_metadata(issue, index, run_dir)
|
| 430 |
+
output_path = Path(clip["path"])
|
| 431 |
+
start_sec = float(clip["clip_start_sec"])
|
| 432 |
+
end_sec = float(clip["clip_end_sec"])
|
| 433 |
+
written = _write_issue_clip_ffmpeg(source_path, output_path, start_sec, end_sec)
|
| 434 |
+
if not written:
|
| 435 |
+
written = _write_issue_clip_cv2(
|
| 436 |
+
source_path,
|
| 437 |
+
output_path,
|
| 438 |
+
start_sec,
|
| 439 |
+
end_sec,
|
| 440 |
+
fps,
|
| 441 |
+
width,
|
| 442 |
+
height,
|
| 443 |
+
)
|
| 444 |
+
if written:
|
| 445 |
+
clips.append(clip)
|
| 446 |
+
return clips
|
| 447 |
+
|
| 448 |
+
|
| 449 |
+
def _write_issue_clip_ffmpeg(
|
| 450 |
+
source_path: Path,
|
| 451 |
+
output_path: Path,
|
| 452 |
+
start_sec: float,
|
| 453 |
+
end_sec: float,
|
| 454 |
+
) -> bool:
|
| 455 |
+
ffmpeg = _tool_path("ffmpeg")
|
| 456 |
+
if ffmpeg is None:
|
| 457 |
+
return False
|
| 458 |
+
|
| 459 |
+
duration = max(0.1, end_sec - start_sec)
|
| 460 |
+
command = [
|
| 461 |
+
ffmpeg,
|
| 462 |
+
"-y",
|
| 463 |
+
"-v",
|
| 464 |
+
"error",
|
| 465 |
+
"-ss",
|
| 466 |
+
f"{start_sec:.3f}",
|
| 467 |
+
"-i",
|
| 468 |
+
str(source_path),
|
| 469 |
+
"-t",
|
| 470 |
+
f"{duration:.3f}",
|
| 471 |
+
"-an",
|
| 472 |
+
"-c:v",
|
| 473 |
+
"libx264",
|
| 474 |
+
"-preset",
|
| 475 |
+
"veryfast",
|
| 476 |
+
"-crf",
|
| 477 |
+
"22",
|
| 478 |
+
"-pix_fmt",
|
| 479 |
+
"yuv420p",
|
| 480 |
+
*BT709_COLOR_ARGS,
|
| 481 |
+
str(output_path),
|
| 482 |
+
]
|
| 483 |
+
try:
|
| 484 |
+
subprocess.run(command, check=True, capture_output=True, timeout=60)
|
| 485 |
+
except (subprocess.SubprocessError, OSError):
|
| 486 |
+
return False
|
| 487 |
+
return output_path.exists() and output_path.stat().st_size > 0
|
| 488 |
+
|
| 489 |
+
|
| 490 |
+
def _write_issue_clip_cv2(
|
| 491 |
+
source_path: Path,
|
| 492 |
+
output_path: Path,
|
| 493 |
+
start_sec: float,
|
| 494 |
+
end_sec: float,
|
| 495 |
+
fps: float,
|
| 496 |
+
width: int,
|
| 497 |
+
height: int,
|
| 498 |
+
) -> bool:
|
| 499 |
+
capture = cv2.VideoCapture(str(source_path))
|
| 500 |
+
if not capture.isOpened():
|
| 501 |
+
capture.release()
|
| 502 |
+
return False
|
| 503 |
+
|
| 504 |
+
source_fps = fps if fps > 0 else capture.get(cv2.CAP_PROP_FPS) or 30.0
|
| 505 |
+
source_width = width or int(capture.get(cv2.CAP_PROP_FRAME_WIDTH) or 0)
|
| 506 |
+
source_height = height or int(capture.get(cv2.CAP_PROP_FRAME_HEIGHT) or 0)
|
| 507 |
+
if source_width <= 0 or source_height <= 0:
|
| 508 |
+
capture.release()
|
| 509 |
+
return False
|
| 510 |
+
|
| 511 |
+
start_frame = max(0, round(start_sec * source_fps))
|
| 512 |
+
end_frame = max(start_frame + 1, round(end_sec * source_fps))
|
| 513 |
+
capture.set(cv2.CAP_PROP_POS_FRAMES, start_frame)
|
| 514 |
+
writer, _codec = _open_video_writer(output_path, source_fps, source_width, source_height)
|
| 515 |
+
if writer is None:
|
| 516 |
+
capture.release()
|
| 517 |
+
return False
|
| 518 |
+
|
| 519 |
+
try:
|
| 520 |
+
frame_index = start_frame
|
| 521 |
+
while frame_index <= end_frame:
|
| 522 |
+
ok, frame = capture.read()
|
| 523 |
+
if not ok or frame is None:
|
| 524 |
+
break
|
| 525 |
+
writer.write(frame)
|
| 526 |
+
frame_index += 1
|
| 527 |
+
finally:
|
| 528 |
+
writer.release()
|
| 529 |
+
capture.release()
|
| 530 |
+
|
| 531 |
+
return output_path.exists() and output_path.stat().st_size > 0
|
| 532 |
+
|
| 533 |
+
|
| 534 |
def _draw_overlays(
|
| 535 |
frame: Any,
|
| 536 |
frame_index: int,
|
| 537 |
rep_count: int,
|
| 538 |
issue_count: int,
|
| 539 |
boundary_labels: dict[int, list[str]],
|
| 540 |
+
phase_label: str | None,
|
| 541 |
+
active_issues: list[IssueMarker],
|
| 542 |
+
quality_warnings: list[str],
|
| 543 |
) -> None:
|
| 544 |
cv2.putText(
|
| 545 |
frame,
|
|
|
|
| 561 |
2,
|
| 562 |
cv2.LINE_AA,
|
| 563 |
)
|
| 564 |
+
labels = list(boundary_labels.get(frame_index, []))
|
| 565 |
+
if phase_label:
|
| 566 |
+
labels.append(phase_label)
|
| 567 |
+
primary_issue = _primary_issue(active_issues)
|
| 568 |
+
if primary_issue is not None:
|
| 569 |
+
labels.append(
|
| 570 |
+
f"{primary_issue.issue} severity {round(primary_issue.severity * 100)}%"
|
| 571 |
+
)
|
| 572 |
+
warning = _confidence_warning(active_issues, quality_warnings)
|
| 573 |
+
if warning is not None:
|
| 574 |
+
labels.append(warning)
|
| 575 |
+
|
| 576 |
for offset, label in enumerate(labels):
|
| 577 |
+
color = (80, 180, 255)
|
| 578 |
+
if primary_issue is not None and label.startswith(primary_issue.issue):
|
| 579 |
+
color = ISSUE_EDGE_COLOR
|
| 580 |
+
elif label.startswith("Low confidence") or label.startswith("Camera warning"):
|
| 581 |
+
color = (0, 210, 255)
|
| 582 |
cv2.putText(
|
| 583 |
frame,
|
| 584 |
label,
|
| 585 |
(16, 96 + offset * 28),
|
| 586 |
cv2.FONT_HERSHEY_SIMPLEX,
|
| 587 |
0.75,
|
| 588 |
+
color,
|
| 589 |
2,
|
| 590 |
cv2.LINE_AA,
|
| 591 |
)
|
|
|
|
| 616 |
reps: Reps,
|
| 617 |
issues: IssueMarkers,
|
| 618 |
run_dir: Path,
|
| 619 |
+
) -> RenderArtifacts:
|
| 620 |
if not manifest.analysis_allowed or not manifest.video_path:
|
| 621 |
+
return RenderArtifacts(
|
| 622 |
+
annotated_video_path=manifest.video_path,
|
| 623 |
+
issue_thumbnail_paths=[],
|
| 624 |
+
issue_clip_paths=[],
|
| 625 |
+
)
|
| 626 |
|
| 627 |
+
run_dir.mkdir(parents=True, exist_ok=True)
|
| 628 |
source_path = Path(manifest.video_path)
|
| 629 |
color_metadata = _video_color_metadata(manifest.video_path)
|
| 630 |
render_input_path = source_path
|
|
|
|
| 640 |
capture.release()
|
| 641 |
for temporary_path in temporary_paths:
|
| 642 |
temporary_path.unlink(missing_ok=True)
|
| 643 |
+
return RenderArtifacts(
|
| 644 |
+
annotated_video_path=manifest.video_path,
|
| 645 |
+
issue_thumbnail_paths=[],
|
| 646 |
+
issue_clip_paths=[],
|
| 647 |
+
)
|
| 648 |
|
| 649 |
fps = manifest.fps if manifest.fps > 0 else 30.0
|
| 650 |
width = manifest.width or int(capture.get(cv2.CAP_PROP_FRAME_WIDTH) or 0)
|
| 651 |
height = manifest.height or int(capture.get(cv2.CAP_PROP_FRAME_HEIGHT) or 0)
|
| 652 |
if width <= 0 or height <= 0:
|
| 653 |
capture.release()
|
| 654 |
+
return RenderArtifacts(
|
| 655 |
+
annotated_video_path=manifest.video_path,
|
| 656 |
+
issue_thumbnail_paths=[],
|
| 657 |
+
issue_clip_paths=[],
|
| 658 |
+
)
|
| 659 |
|
| 660 |
output_path = run_dir / "annotated_video.mp4"
|
| 661 |
raw_output_path = run_dir / "annotated_video_raw.mp4" if _tool_path("ffmpeg") else output_path
|
|
|
|
| 664 |
capture.release()
|
| 665 |
for temporary_path in temporary_paths:
|
| 666 |
temporary_path.unlink(missing_ok=True)
|
| 667 |
+
return RenderArtifacts(
|
| 668 |
+
annotated_video_path=manifest.video_path,
|
| 669 |
+
issue_thumbnail_paths=[],
|
| 670 |
+
issue_clip_paths=[],
|
| 671 |
+
)
|
| 672 |
|
| 673 |
pose_by_frame = {frame.frame_index: frame for frame in pose_sequence.frames}
|
| 674 |
ordered_pose_frames = sorted(pose_sequence.frames, key=lambda frame: frame.frame_index)
|
| 675 |
pose_cursor = 0
|
| 676 |
last_pose_frame = None
|
| 677 |
boundary_labels = _rep_boundaries(reps)
|
| 678 |
+
thumbnail_targets = _thumbnail_targets(issues, run_dir)
|
| 679 |
+
issue_thumbnail_paths: list[dict[str, Any]] = []
|
| 680 |
|
| 681 |
try:
|
| 682 |
frame_index = 0
|
|
|
|
| 697 |
|
| 698 |
exact_pose = pose_by_frame.get(frame_index)
|
| 699 |
active_pose = exact_pose or last_pose_frame
|
| 700 |
+
active_issues = _active_issues(frame_index, issues)
|
| 701 |
if active_pose is not None and active_pose.landmarks:
|
| 702 |
points = _frame_landmark_points(active_pose.landmarks, width, height)
|
| 703 |
+
_draw_pose(frame, points, _highlighted_joints(active_issues))
|
| 704 |
+
_draw_angle_labels(frame, points, active_issues)
|
| 705 |
+
|
| 706 |
+
_draw_overlays(
|
| 707 |
+
frame,
|
| 708 |
+
frame_index,
|
| 709 |
+
len(reps.reps),
|
| 710 |
+
len(issues.issues),
|
| 711 |
+
boundary_labels,
|
| 712 |
+
_rep_phase(frame_index, reps),
|
| 713 |
+
active_issues,
|
| 714 |
+
manifest.quality_warnings,
|
| 715 |
+
)
|
| 716 |
+
for thumbnail in thumbnail_targets.get(frame_index, []):
|
| 717 |
+
if cv2.imwrite(thumbnail["path"], frame):
|
| 718 |
+
issue_thumbnail_paths.append(thumbnail)
|
| 719 |
writer.write(frame)
|
| 720 |
frame_index += 1
|
| 721 |
finally:
|
|
|
|
| 730 |
raw_output_path.replace(output_path)
|
| 731 |
raw_output_path.unlink(missing_ok=True)
|
| 732 |
|
| 733 |
+
return RenderArtifacts(
|
| 734 |
+
annotated_video_path=str(output_path),
|
| 735 |
+
issue_thumbnail_paths=issue_thumbnail_paths,
|
| 736 |
+
issue_clip_paths=_issue_clip_paths(
|
| 737 |
+
output_path,
|
| 738 |
+
issues,
|
| 739 |
+
run_dir,
|
| 740 |
+
fps,
|
| 741 |
+
width,
|
| 742 |
+
height,
|
| 743 |
+
),
|
| 744 |
+
)
|
tests/test_annotated_renderer.py
CHANGED
|
@@ -10,7 +10,15 @@ import numpy as np
|
|
| 10 |
|
| 11 |
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
| 12 |
|
| 13 |
-
from pozify.contracts import
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
from pozify.steps import annotated_renderer
|
| 15 |
|
| 16 |
|
|
@@ -84,7 +92,7 @@ class AnnotatedRendererTests(unittest.TestCase):
|
|
| 84 |
partial_reps=[],
|
| 85 |
)
|
| 86 |
|
| 87 |
-
|
| 88 |
manifest,
|
| 89 |
pose_sequence,
|
| 90 |
reps,
|
|
@@ -92,8 +100,116 @@ class AnnotatedRendererTests(unittest.TestCase):
|
|
| 92 |
Path(self.temp_dir.name),
|
| 93 |
)
|
| 94 |
|
| 95 |
-
self.assertIsNotNone(
|
| 96 |
-
self.assertTrue(Path(str(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
|
| 98 |
def test_hdr_metadata_requires_sdr_conversion(self) -> None:
|
| 99 |
self.assertTrue(
|
|
|
|
| 10 |
|
| 11 |
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
| 12 |
|
| 13 |
+
from pozify.contracts import (
|
| 14 |
+
IssueMarker,
|
| 15 |
+
IssueMarkers,
|
| 16 |
+
PoseFrame,
|
| 17 |
+
PoseSequence,
|
| 18 |
+
Rep,
|
| 19 |
+
Reps,
|
| 20 |
+
VideoManifest,
|
| 21 |
+
)
|
| 22 |
from pozify.steps import annotated_renderer
|
| 23 |
|
| 24 |
|
|
|
|
| 92 |
partial_reps=[],
|
| 93 |
)
|
| 94 |
|
| 95 |
+
result = annotated_renderer.run(
|
| 96 |
manifest,
|
| 97 |
pose_sequence,
|
| 98 |
reps,
|
|
|
|
| 100 |
Path(self.temp_dir.name),
|
| 101 |
)
|
| 102 |
|
| 103 |
+
self.assertIsNotNone(result.annotated_video_path)
|
| 104 |
+
self.assertTrue(Path(str(result.annotated_video_path)).exists())
|
| 105 |
+
self.assertEqual(result.issue_thumbnail_paths, [])
|
| 106 |
+
self.assertEqual(result.issue_clip_paths, [])
|
| 107 |
+
|
| 108 |
+
def test_renderer_highlights_active_issue_and_writes_thumbnail(self) -> None:
|
| 109 |
+
video_path = self._write_video()
|
| 110 |
+
manifest = VideoManifest(
|
| 111 |
+
video_path=str(video_path),
|
| 112 |
+
fps=30.0,
|
| 113 |
+
duration_sec=0.2,
|
| 114 |
+
total_frames=6,
|
| 115 |
+
sampled_frames=6,
|
| 116 |
+
width=320,
|
| 117 |
+
height=240,
|
| 118 |
+
codec="mp4v",
|
| 119 |
+
container="mp4",
|
| 120 |
+
brightness_mean=100.0,
|
| 121 |
+
blur_laplacian_var=100.0,
|
| 122 |
+
quality_warnings=[],
|
| 123 |
+
analysis_allowed=True,
|
| 124 |
+
)
|
| 125 |
+
pose_sequence = PoseSequence(
|
| 126 |
+
frames=[_frame(index) for index in range(6)],
|
| 127 |
+
normalized=True,
|
| 128 |
+
smoothing_method="exponential_smoothing",
|
| 129 |
+
pose_valid_ratio=1.0,
|
| 130 |
+
)
|
| 131 |
+
reps = Reps(
|
| 132 |
+
exercise="push_up",
|
| 133 |
+
reps=[Rep(1, 0, 2, 5, 0.0, 0.067, 0.167)],
|
| 134 |
+
partial_reps=[],
|
| 135 |
+
)
|
| 136 |
+
issues = IssueMarkers(
|
| 137 |
+
issues=[
|
| 138 |
+
IssueMarker(
|
| 139 |
+
rep_id=1,
|
| 140 |
+
issue="hip_sag",
|
| 141 |
+
severity=0.82,
|
| 142 |
+
start_frame=2,
|
| 143 |
+
end_frame=4,
|
| 144 |
+
start_sec=0.067,
|
| 145 |
+
end_sec=0.133,
|
| 146 |
+
affected_joints=["left_hip"],
|
| 147 |
+
evidence={
|
| 148 |
+
"body_line_score": 0.42,
|
| 149 |
+
"threshold": 0.6,
|
| 150 |
+
"confidence": 0.54,
|
| 151 |
+
"peak_frame": 3,
|
| 152 |
+
},
|
| 153 |
+
)
|
| 154 |
+
]
|
| 155 |
+
)
|
| 156 |
+
|
| 157 |
+
result = annotated_renderer.run(
|
| 158 |
+
manifest,
|
| 159 |
+
pose_sequence,
|
| 160 |
+
reps,
|
| 161 |
+
issues,
|
| 162 |
+
Path(self.temp_dir.name),
|
| 163 |
+
)
|
| 164 |
+
|
| 165 |
+
self.assertIsNotNone(result.annotated_video_path)
|
| 166 |
+
self.assertEqual(len(result.issue_thumbnail_paths), 1)
|
| 167 |
+
self.assertTrue(Path(result.issue_thumbnail_paths[0]["path"]).exists())
|
| 168 |
+
self.assertEqual(len(result.issue_clip_paths), 1)
|
| 169 |
+
self.assertTrue(Path(result.issue_clip_paths[0]["path"]).exists())
|
| 170 |
+
self.assertEqual(result.issue_clip_paths[0]["clip_start_sec"], 0.0)
|
| 171 |
+
self.assertGreater(result.issue_clip_paths[0]["clip_end_sec"], 1.0)
|
| 172 |
+
|
| 173 |
+
capture = cv2.VideoCapture(str(result.annotated_video_path))
|
| 174 |
+
frames = []
|
| 175 |
+
for _ in range(4):
|
| 176 |
+
ok, frame = capture.read()
|
| 177 |
+
self.assertTrue(ok)
|
| 178 |
+
frames.append(frame)
|
| 179 |
+
capture.release()
|
| 180 |
+
|
| 181 |
+
left_hip_x, left_hip_y = 134, 132
|
| 182 |
+
active_roi = frames[3][left_hip_y - 8 : left_hip_y + 9, left_hip_x - 8 : left_hip_x + 9]
|
| 183 |
+
inactive_roi = frames[0][left_hip_y - 8 : left_hip_y + 9, left_hip_x - 8 : left_hip_x + 9]
|
| 184 |
+
active_red_pixels = np.count_nonzero(
|
| 185 |
+
(active_roi[:, :, 2] > 180) & (active_roi[:, :, 1] < 180) & (active_roi[:, :, 0] < 120)
|
| 186 |
+
)
|
| 187 |
+
inactive_red_pixels = np.count_nonzero(
|
| 188 |
+
(inactive_roi[:, :, 2] > 180) & (inactive_roi[:, :, 1] < 180) & (inactive_roi[:, :, 0] < 120)
|
| 189 |
+
)
|
| 190 |
+
self.assertGreater(active_red_pixels, inactive_red_pixels * 4)
|
| 191 |
+
|
| 192 |
+
def test_angle_label_uses_degree_evidence(self) -> None:
|
| 193 |
+
issue = IssueMarker(
|
| 194 |
+
rep_id=1,
|
| 195 |
+
issue="incomplete_depth",
|
| 196 |
+
severity=0.7,
|
| 197 |
+
start_frame=2,
|
| 198 |
+
end_frame=4,
|
| 199 |
+
start_sec=0.067,
|
| 200 |
+
end_sec=0.133,
|
| 201 |
+
affected_joints=["left_elbow", "right_elbow"],
|
| 202 |
+
evidence={
|
| 203 |
+
"elbow_angle_deg": 123.4,
|
| 204 |
+
"threshold": 115.0,
|
| 205 |
+
"confidence": 0.74,
|
| 206 |
+
},
|
| 207 |
+
)
|
| 208 |
+
|
| 209 |
+
self.assertEqual(
|
| 210 |
+
annotated_renderer._issue_angle_label(issue),
|
| 211 |
+
"elbow angle 123 deg",
|
| 212 |
+
)
|
| 213 |
|
| 214 |
def test_hdr_metadata_requires_sdr_conversion(self) -> None:
|
| 215 |
self.assertTrue(
|
tests/test_issue_marker.py
CHANGED
|
@@ -162,6 +162,9 @@ class IssueMarkerTests(unittest.TestCase):
|
|
| 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",
|
|
|
|
| 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.assertIn("peak_frame", hip_sag.evidence)
|
| 166 |
+
self.assertGreaterEqual(hip_sag.evidence["peak_frame"], hip_sag.start_frame)
|
| 167 |
+
self.assertLessEqual(hip_sag.evidence["peak_frame"], hip_sag.end_frame)
|
| 168 |
self.assertEqual(
|
| 169 |
hip_sag.evidence["variation_context"]["detected_variation"],
|
| 170 |
"wide_grip_push_up",
|
tests/test_pipeline_contracts.py
CHANGED
|
@@ -136,6 +136,11 @@ class PipelineContractTests(unittest.TestCase):
|
|
| 136 |
self.assertTrue(artifact_path.exists(), artifact_name)
|
| 137 |
payload = json.loads(artifact_path.read_text(encoding="utf-8"))
|
| 138 |
self.assertEqual(sorted(payload.keys()), keys, artifact_name)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 139 |
|
| 140 |
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
| 141 |
self.assertTrue(manifest["mock_mode"])
|
|
|
|
| 136 |
self.assertTrue(artifact_path.exists(), artifact_name)
|
| 137 |
payload = json.loads(artifact_path.read_text(encoding="utf-8"))
|
| 138 |
self.assertEqual(sorted(payload.keys()), keys, artifact_name)
|
| 139 |
+
if artifact_name == "final_report.json":
|
| 140 |
+
self.assertIn("issue_thumbnail_paths", payload["artifacts"])
|
| 141 |
+
self.assertIsInstance(payload["artifacts"]["issue_thumbnail_paths"], list)
|
| 142 |
+
self.assertIn("issue_clip_paths", payload["artifacts"])
|
| 143 |
+
self.assertIsInstance(payload["artifacts"]["issue_clip_paths"], list)
|
| 144 |
|
| 145 |
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
| 146 |
self.assertTrue(manifest["mock_mode"])
|
web/app.js
CHANGED
|
@@ -1,4 +1,8 @@
|
|
| 1 |
-
import React, {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
import { createRoot } from "https://esm.sh/react-dom@18.2.0/client";
|
| 3 |
|
| 4 |
const h = React.createElement;
|
|
@@ -6,7 +10,13 @@ const h = React.createElement;
|
|
| 6 |
const defaults = {
|
| 7 |
description:
|
| 8 |
"Upload a short workout clip, tune the athlete context, and generate an annotated form-review report with structured artifacts.",
|
| 9 |
-
goals: [
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
experience_levels: ["beginner", "intermediate"],
|
| 11 |
exercises: ["auto"],
|
| 12 |
limitations: ["wrist_discomfort", "knee_discomfort", "shoulder_discomfort"],
|
|
@@ -33,12 +43,26 @@ function SelectField({ labelText, value, onChange, options }) {
|
|
| 33 |
h(
|
| 34 |
"select",
|
| 35 |
{ value, onChange: (event) => onChange(event.target.value) },
|
| 36 |
-
options.map((option) =>
|
|
|
|
|
|
|
| 37 |
),
|
| 38 |
);
|
| 39 |
}
|
| 40 |
|
| 41 |
-
function
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
if (!result) {
|
| 43 |
return h(
|
| 44 |
"section",
|
|
@@ -54,10 +78,10 @@ function Summary({ result }) {
|
|
| 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 |
|
| 63 |
return h(
|
|
@@ -69,7 +93,12 @@ function Summary({ result }) {
|
|
| 69 |
"div",
|
| 70 |
{ className: "stat-grid" },
|
| 71 |
stats.map(([name, value]) =>
|
| 72 |
-
h(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
),
|
| 74 |
),
|
| 75 |
h(
|
|
@@ -79,55 +108,250 @@ function Summary({ result }) {
|
|
| 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",
|
| 86 |
{ className: "quality-list" },
|
| 87 |
-
warnings.map((warning) =>
|
|
|
|
|
|
|
| 88 |
)
|
| 89 |
-
: h(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
);
|
| 91 |
}
|
| 92 |
|
| 93 |
function issueEvidence(issue) {
|
| 94 |
const entries = Object.entries(issue.evidence || {}).filter(
|
| 95 |
([key, value]) =>
|
| 96 |
-
![
|
| 97 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
return h(
|
| 105 |
"section",
|
| 106 |
-
{ className: "
|
| 107 |
h(
|
| 108 |
"div",
|
| 109 |
{ className: "timeline-head" },
|
| 110 |
h("h3", null, "Issue timeline"),
|
| 111 |
-
h(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 112 |
),
|
| 113 |
issues.length
|
| 114 |
? h(
|
| 115 |
"div",
|
| 116 |
-
{ className: "
|
| 117 |
issues.map((issue, index) =>
|
| 118 |
h(
|
| 119 |
"article",
|
| 120 |
-
{
|
|
|
|
|
|
|
|
|
|
|
|
|
| 121 |
h(
|
| 122 |
"div",
|
| 123 |
{ className: "timeline-main" },
|
| 124 |
h("strong", null, label(issue.issue)),
|
| 125 |
-
h("span", null,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 126 |
),
|
| 127 |
h(
|
| 128 |
"div",
|
| 129 |
{ className: "timeline-meta" },
|
| 130 |
-
h(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 131 |
h("span", null, issueEvidence(issue)),
|
| 132 |
h("span", null, issue.affected_joints.map(label).join(", ")),
|
| 133 |
),
|
|
@@ -138,12 +362,128 @@ function IssueTimeline({ issues }) {
|
|
| 138 |
);
|
| 139 |
}
|
| 140 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 141 |
function NoteList({ title, items }) {
|
| 142 |
return h(
|
| 143 |
"article",
|
| 144 |
{ className: "note" },
|
| 145 |
h("h3", null, title),
|
| 146 |
-
h(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 147 |
);
|
| 148 |
}
|
| 149 |
|
|
@@ -157,11 +497,14 @@ function App() {
|
|
| 157 |
const [equipment, setEquipment] = useState("bodyweight");
|
| 158 |
const [limitations, setLimitations] = useState([]);
|
| 159 |
const [result, setResult] = useState(null);
|
| 160 |
-
const [activeTab, setActiveTab] = useState("
|
| 161 |
const [status, setStatus] = useState("idle");
|
| 162 |
const [error, setError] = useState("");
|
| 163 |
|
| 164 |
-
const previewUrl = useMemo(
|
|
|
|
|
|
|
|
|
|
| 165 |
|
| 166 |
useEffect(() => {
|
| 167 |
fetch("/api/config")
|
|
@@ -178,7 +521,9 @@ function App() {
|
|
| 178 |
|
| 179 |
function toggleLimitation(value) {
|
| 180 |
setLimitations((current) =>
|
| 181 |
-
current.includes(value)
|
|
|
|
|
|
|
| 182 |
);
|
| 183 |
}
|
| 184 |
|
|
@@ -198,7 +543,10 @@ function App() {
|
|
| 198 |
payload.append("equipment", equipment);
|
| 199 |
|
| 200 |
try {
|
| 201 |
-
const response = await fetch("/api/analyze", {
|
|
|
|
|
|
|
|
|
|
| 202 |
const body = await response.json();
|
| 203 |
if (!response.ok) throw new Error(body.detail || "Analysis failed.");
|
| 204 |
setResult(body);
|
|
@@ -218,16 +566,35 @@ function App() {
|
|
| 218 |
h(
|
| 219 |
"div",
|
| 220 |
{ className: "hero-content" },
|
| 221 |
-
h(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 222 |
h("h1", null, "Pozify"),
|
| 223 |
h("p", null, config.description),
|
| 224 |
),
|
| 225 |
h(
|
| 226 |
"div",
|
| 227 |
{ className: "hero-metrics", "aria-label": "Pipeline highlights" },
|
| 228 |
-
h(
|
| 229 |
-
|
| 230 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 231 |
),
|
| 232 |
),
|
| 233 |
h(
|
|
@@ -239,8 +606,17 @@ function App() {
|
|
| 239 |
h(
|
| 240 |
"div",
|
| 241 |
{ className: "panel-head" },
|
| 242 |
-
h(
|
| 243 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 244 |
),
|
| 245 |
h(
|
| 246 |
"label",
|
|
@@ -251,7 +627,11 @@ function App() {
|
|
| 251 |
onChange: (event) => setFile(event.target.files?.[0] || null),
|
| 252 |
}),
|
| 253 |
previewUrl
|
| 254 |
-
? h("video", {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 255 |
: h(
|
| 256 |
"span",
|
| 257 |
{ className: "dropzone-empty" },
|
|
@@ -263,7 +643,12 @@ function App() {
|
|
| 263 |
h(
|
| 264 |
"div",
|
| 265 |
{ className: "form-grid" },
|
| 266 |
-
h(SelectField, {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 267 |
h(SelectField, {
|
| 268 |
labelText: "Experience",
|
| 269 |
value: experience,
|
|
@@ -314,7 +699,11 @@ function App() {
|
|
| 314 |
),
|
| 315 |
),
|
| 316 |
),
|
| 317 |
-
h(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 318 |
error ? h("div", { className: "error" }, error) : null,
|
| 319 |
),
|
| 320 |
h(
|
|
@@ -323,14 +712,25 @@ function App() {
|
|
| 323 |
h(
|
| 324 |
"div",
|
| 325 |
{ className: "panel-head" },
|
| 326 |
-
h(
|
| 327 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 328 |
),
|
| 329 |
h(
|
| 330 |
"div",
|
| 331 |
{ className: "result-stage" },
|
| 332 |
result?.annotated_video_url
|
| 333 |
-
? h("video", {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 334 |
: h(
|
| 335 |
"div",
|
| 336 |
{ className: "result-empty" },
|
|
@@ -340,41 +740,7 @@ function App() {
|
|
| 340 |
),
|
| 341 |
),
|
| 342 |
),
|
| 343 |
-
h(
|
| 344 |
-
h(
|
| 345 |
-
"div",
|
| 346 |
-
{ className: "tabs", role: "tablist", "aria-label": "Artifacts" },
|
| 347 |
-
["json", "artifacts"].map((tab) =>
|
| 348 |
-
h(
|
| 349 |
-
"button",
|
| 350 |
-
{
|
| 351 |
-
className: `tab${activeTab === tab ? " active" : ""}`,
|
| 352 |
-
key: tab,
|
| 353 |
-
onClick: () => setActiveTab(tab),
|
| 354 |
-
type: "button",
|
| 355 |
-
},
|
| 356 |
-
tab === "json" ? "Final report JSON" : "Artifact links",
|
| 357 |
-
),
|
| 358 |
-
),
|
| 359 |
-
),
|
| 360 |
-
h(
|
| 361 |
-
"section",
|
| 362 |
-
{ className: "artifact-panel" },
|
| 363 |
-
activeTab === "json"
|
| 364 |
-
? h("pre", { className: "json-block" }, result ? JSON.stringify(result.report, null, 2) : "{}")
|
| 365 |
-
: h(
|
| 366 |
-
"div",
|
| 367 |
-
{ className: "artifact-links" },
|
| 368 |
-
result
|
| 369 |
-
? [
|
| 370 |
-
h("a", { className: "artifact-link", key: "report", href: result.final_report_url }, "final_report.json", h("span", null, "open")),
|
| 371 |
-
result.annotated_video_url
|
| 372 |
-
? h("a", { className: "artifact-link", key: "video", href: result.annotated_video_url }, "annotated_video.mp4", h("span", null, "open"))
|
| 373 |
-
: null,
|
| 374 |
-
]
|
| 375 |
-
: h("p", null, "Artifacts appear after analysis."),
|
| 376 |
-
),
|
| 377 |
-
),
|
| 378 |
);
|
| 379 |
}
|
| 380 |
|
|
|
|
| 1 |
+
import React, {
|
| 2 |
+
useEffect,
|
| 3 |
+
useMemo,
|
| 4 |
+
useState,
|
| 5 |
+
} from "https://esm.sh/react@18.2.0";
|
| 6 |
import { createRoot } from "https://esm.sh/react-dom@18.2.0/client";
|
| 7 |
|
| 8 |
const h = React.createElement;
|
|
|
|
| 10 |
const defaults = {
|
| 11 |
description:
|
| 12 |
"Upload a short workout clip, tune the athlete context, and generate an annotated form-review report with structured artifacts.",
|
| 13 |
+
goals: [
|
| 14 |
+
"strength",
|
| 15 |
+
"hypertrophy",
|
| 16 |
+
"endurance",
|
| 17 |
+
"mobility",
|
| 18 |
+
"beginner_practice",
|
| 19 |
+
],
|
| 20 |
experience_levels: ["beginner", "intermediate"],
|
| 21 |
exercises: ["auto"],
|
| 22 |
limitations: ["wrist_discomfort", "knee_discomfort", "shoulder_discomfort"],
|
|
|
|
| 43 |
h(
|
| 44 |
"select",
|
| 45 |
{ value, onChange: (event) => onChange(event.target.value) },
|
| 46 |
+
options.map((option) =>
|
| 47 |
+
h("option", { key: option, value: option }, label(option)),
|
| 48 |
+
),
|
| 49 |
),
|
| 50 |
);
|
| 51 |
}
|
| 52 |
|
| 53 |
+
function formatValue(value) {
|
| 54 |
+
if (typeof value === "number")
|
| 55 |
+
return Number.isInteger(value) ? String(value) : value.toFixed(3);
|
| 56 |
+
if (Array.isArray(value)) return value.map(formatValue).join(", ");
|
| 57 |
+
if (value && typeof value === "object") return JSON.stringify(value);
|
| 58 |
+
return value ?? "n/a";
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
function percent(value) {
|
| 62 |
+
return typeof value === "number" ? `${Math.round(value * 100)}%` : "n/a";
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
function SummaryTab({ result }) {
|
| 66 |
if (!result) {
|
| 67 |
return h(
|
| 68 |
"section",
|
|
|
|
| 78 |
const issues = report.issue_markers?.issues || [];
|
| 79 |
const stats = [
|
| 80 |
["Exercise", report.exercise.exercise],
|
| 81 |
+
["Confidence", percent(report.exercise.confidence)],
|
| 82 |
["Variation", report.variation.detected_variation],
|
| 83 |
["Reps", String(report.reps.reps.length)],
|
| 84 |
["Issues", String(issues.length)],
|
|
|
|
| 85 |
];
|
| 86 |
|
| 87 |
return h(
|
|
|
|
| 93 |
"div",
|
| 94 |
{ className: "stat-grid" },
|
| 95 |
stats.map(([name, value]) =>
|
| 96 |
+
h(
|
| 97 |
+
"div",
|
| 98 |
+
{ className: "stat", key: name },
|
| 99 |
+
h("span", null, name),
|
| 100 |
+
h("strong", null, value),
|
| 101 |
+
),
|
| 102 |
),
|
| 103 |
),
|
| 104 |
h(
|
|
|
|
| 108 |
h(NoteList, { title: "Top fixes", items: summary.top_fixes }),
|
| 109 |
h(NoteList, { title: "Next session", items: summary.next_session_plan }),
|
| 110 |
),
|
|
|
|
| 111 |
warnings.length
|
| 112 |
? h(
|
| 113 |
"div",
|
| 114 |
{ className: "quality-list" },
|
| 115 |
+
warnings.map((warning) =>
|
| 116 |
+
h("span", { key: warning }, label(warning)),
|
| 117 |
+
),
|
| 118 |
)
|
| 119 |
+
: h(
|
| 120 |
+
"div",
|
| 121 |
+
{ className: "quality-list" },
|
| 122 |
+
h("span", null, "No quality warnings"),
|
| 123 |
+
),
|
| 124 |
+
);
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
function MetricsTab({ result }) {
|
| 128 |
+
if (!result)
|
| 129 |
+
return h(
|
| 130 |
+
"section",
|
| 131 |
+
{ className: "summary" },
|
| 132 |
+
h("h2", null, "Movement metrics"),
|
| 133 |
+
h("p", null, "Metrics appear after analysis."),
|
| 134 |
+
);
|
| 135 |
+
const report = result.report;
|
| 136 |
+
const aggregate = Object.entries(
|
| 137 |
+
report.rep_analysis?.aggregate_metrics || {},
|
| 138 |
+
);
|
| 139 |
+
const reps = report.rep_analysis?.items || [];
|
| 140 |
+
return h(
|
| 141 |
+
"section",
|
| 142 |
+
{ className: "summary" },
|
| 143 |
+
h("h2", null, "Movement metrics"),
|
| 144 |
+
h(
|
| 145 |
+
"div",
|
| 146 |
+
{ className: "metric-grid" },
|
| 147 |
+
aggregate.length
|
| 148 |
+
? aggregate.map(([name, value]) =>
|
| 149 |
+
h(
|
| 150 |
+
"div",
|
| 151 |
+
{ className: "stat", key: name },
|
| 152 |
+
h("span", null, label(name)),
|
| 153 |
+
h("strong", null, formatValue(value)),
|
| 154 |
+
),
|
| 155 |
+
)
|
| 156 |
+
: h("p", null, "No aggregate metrics available."),
|
| 157 |
+
),
|
| 158 |
+
h(
|
| 159 |
+
"div",
|
| 160 |
+
{ className: "table-wrap" },
|
| 161 |
+
h(
|
| 162 |
+
"table",
|
| 163 |
+
null,
|
| 164 |
+
h(
|
| 165 |
+
"thead",
|
| 166 |
+
null,
|
| 167 |
+
h(
|
| 168 |
+
"tr",
|
| 169 |
+
null,
|
| 170 |
+
["Rep", "Duration", "ROM", "Stability", "Symmetry"].map((heading) =>
|
| 171 |
+
h("th", { key: heading }, heading),
|
| 172 |
+
),
|
| 173 |
+
),
|
| 174 |
+
),
|
| 175 |
+
h(
|
| 176 |
+
"tbody",
|
| 177 |
+
null,
|
| 178 |
+
reps.map((rep) =>
|
| 179 |
+
h(
|
| 180 |
+
"tr",
|
| 181 |
+
{ key: rep.rep_id },
|
| 182 |
+
h("td", null, rep.rep_id),
|
| 183 |
+
h("td", null, `${formatValue(rep.duration_sec)}s`),
|
| 184 |
+
h("td", null, percent(rep.range_of_motion_score)),
|
| 185 |
+
h("td", null, percent(rep.stability_score)),
|
| 186 |
+
h("td", null, percent(rep.symmetry_score)),
|
| 187 |
+
),
|
| 188 |
+
),
|
| 189 |
+
),
|
| 190 |
+
),
|
| 191 |
+
),
|
| 192 |
+
);
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
function RepsTab({ result }) {
|
| 196 |
+
if (!result)
|
| 197 |
+
return h(
|
| 198 |
+
"section",
|
| 199 |
+
{ className: "summary" },
|
| 200 |
+
h("h2", null, "Rep review"),
|
| 201 |
+
h("p", null, "Rep segments appear after analysis."),
|
| 202 |
+
);
|
| 203 |
+
const reps = result.report.reps?.reps || [];
|
| 204 |
+
return h(
|
| 205 |
+
"section",
|
| 206 |
+
{ className: "summary" },
|
| 207 |
+
h("h2", null, "Rep review"),
|
| 208 |
+
reps.length
|
| 209 |
+
? h(
|
| 210 |
+
"div",
|
| 211 |
+
{ className: "rep-grid" },
|
| 212 |
+
reps.map((rep) =>
|
| 213 |
+
h(
|
| 214 |
+
"article",
|
| 215 |
+
{ className: "rep-card", key: rep.rep_id },
|
| 216 |
+
h("strong", null, `Rep ${rep.rep_id}`),
|
| 217 |
+
h(
|
| 218 |
+
"span",
|
| 219 |
+
null,
|
| 220 |
+
`${rep.start_sec.toFixed(2)}s-${rep.end_sec.toFixed(2)}s`,
|
| 221 |
+
),
|
| 222 |
+
h("span", null, `frames ${rep.start_frame}-${rep.end_frame}`),
|
| 223 |
+
h("span", null, `midpoint ${rep.mid_sec.toFixed(2)}s`),
|
| 224 |
+
),
|
| 225 |
+
),
|
| 226 |
+
)
|
| 227 |
+
: h("p", null, "No complete reps were detected."),
|
| 228 |
);
|
| 229 |
}
|
| 230 |
|
| 231 |
function issueEvidence(issue) {
|
| 232 |
const entries = Object.entries(issue.evidence || {}).filter(
|
| 233 |
([key, value]) =>
|
| 234 |
+
![
|
| 235 |
+
"threshold",
|
| 236 |
+
"confidence",
|
| 237 |
+
"variation_context",
|
| 238 |
+
"supporting_frames",
|
| 239 |
+
"fallback",
|
| 240 |
+
].includes(key) && typeof value !== "object",
|
| 241 |
);
|
| 242 |
const [metric, value] = entries[0] || ["metric", "n/a"];
|
| 243 |
return `${label(metric)} ${value} vs ${issue.evidence?.threshold ?? "n/a"}`;
|
| 244 |
}
|
| 245 |
|
| 246 |
+
function thumbnailForIssue(result, issue, index) {
|
| 247 |
+
const thumbnails = result?.issue_thumbnail_urls || [];
|
| 248 |
+
return (
|
| 249 |
+
thumbnails.find(
|
| 250 |
+
(thumbnail) =>
|
| 251 |
+
thumbnail.rep_id === issue.rep_id && thumbnail.issue === issue.issue,
|
| 252 |
+
) || thumbnails[index]
|
| 253 |
+
);
|
| 254 |
+
}
|
| 255 |
+
|
| 256 |
+
function clipForIssue(result, issue, index) {
|
| 257 |
+
const clips = result?.issue_clip_urls || [];
|
| 258 |
+
return (
|
| 259 |
+
clips.find(
|
| 260 |
+
(clip) => clip.rep_id === issue.rep_id && clip.issue === issue.issue,
|
| 261 |
+
) || clips[index]
|
| 262 |
+
);
|
| 263 |
+
}
|
| 264 |
+
|
| 265 |
+
function IssueMedia({ result, issue, index }) {
|
| 266 |
+
const clip = clipForIssue(result, issue, index);
|
| 267 |
+
if (clip?.url) {
|
| 268 |
+
return h("video", {
|
| 269 |
+
className: "issue-clip",
|
| 270 |
+
src: clip.url,
|
| 271 |
+
controls: true,
|
| 272 |
+
muted: true,
|
| 273 |
+
playsInline: true,
|
| 274 |
+
preload: "metadata",
|
| 275 |
+
});
|
| 276 |
+
}
|
| 277 |
+
|
| 278 |
+
const thumbnail = thumbnailForIssue(result, issue, index);
|
| 279 |
+
if (thumbnail?.url) {
|
| 280 |
+
return h("img", {
|
| 281 |
+
className: "issue-thumb",
|
| 282 |
+
src: thumbnail.url,
|
| 283 |
+
alt: `${label(issue.issue)} thumbnail`,
|
| 284 |
+
});
|
| 285 |
+
}
|
| 286 |
+
|
| 287 |
+
return h("div", { className: "issue-thumb empty" }, "No clip");
|
| 288 |
+
}
|
| 289 |
+
|
| 290 |
+
function issueClipText(result, issue, index) {
|
| 291 |
+
const clip = clipForIssue(result, issue, index);
|
| 292 |
+
if (
|
| 293 |
+
!clip ||
|
| 294 |
+
typeof clip.clip_start_sec !== "number" ||
|
| 295 |
+
typeof clip.clip_end_sec !== "number"
|
| 296 |
+
) {
|
| 297 |
+
return `Rep ${issue.rep_id} · ${issue.start_sec.toFixed(2)}s-${issue.end_sec.toFixed(2)}s`;
|
| 298 |
+
}
|
| 299 |
+
return `Rep ${issue.rep_id} · clip ${clip.clip_start_sec.toFixed(2)}s-${clip.clip_end_sec.toFixed(2)}s`;
|
| 300 |
+
}
|
| 301 |
+
|
| 302 |
+
function IssuesTab({ result }) {
|
| 303 |
+
const issues = result?.report?.issue_markers?.issues || [];
|
| 304 |
return h(
|
| 305 |
"section",
|
| 306 |
+
{ className: "summary", "aria-label": "Issue timeline" },
|
| 307 |
h(
|
| 308 |
"div",
|
| 309 |
{ className: "timeline-head" },
|
| 310 |
h("h3", null, "Issue timeline"),
|
| 311 |
+
h(
|
| 312 |
+
"span",
|
| 313 |
+
null,
|
| 314 |
+
issues.length
|
| 315 |
+
? `${issues.length} interval${issues.length === 1 ? "" : "s"}`
|
| 316 |
+
: "clear",
|
| 317 |
+
),
|
| 318 |
),
|
| 319 |
issues.length
|
| 320 |
? h(
|
| 321 |
"div",
|
| 322 |
+
{ className: "issue-card-grid" },
|
| 323 |
issues.map((issue, index) =>
|
| 324 |
h(
|
| 325 |
"article",
|
| 326 |
+
{
|
| 327 |
+
className: "issue-card",
|
| 328 |
+
key: `${issue.rep_id}-${issue.issue}-${index}`,
|
| 329 |
+
},
|
| 330 |
+
h(IssueMedia, { result, issue, index }),
|
| 331 |
h(
|
| 332 |
"div",
|
| 333 |
{ className: "timeline-main" },
|
| 334 |
h("strong", null, label(issue.issue)),
|
| 335 |
+
h("span", null, issueClipText(result, issue, index)),
|
| 336 |
+
h(
|
| 337 |
+
"span",
|
| 338 |
+
null,
|
| 339 |
+
`issue ${issue.start_sec.toFixed(2)}s-${issue.end_sec.toFixed(2)}s`,
|
| 340 |
+
),
|
| 341 |
),
|
| 342 |
h(
|
| 343 |
"div",
|
| 344 |
{ className: "timeline-meta" },
|
| 345 |
+
h(
|
| 346 |
+
"span",
|
| 347 |
+
null,
|
| 348 |
+
`severity ${Math.round(issue.severity * 100)}%`,
|
| 349 |
+
),
|
| 350 |
+
h(
|
| 351 |
+
"span",
|
| 352 |
+
null,
|
| 353 |
+
`confidence ${percent(issue.evidence?.confidence)}`,
|
| 354 |
+
),
|
| 355 |
h("span", null, issueEvidence(issue)),
|
| 356 |
h("span", null, issue.affected_joints.map(label).join(", ")),
|
| 357 |
),
|
|
|
|
| 362 |
);
|
| 363 |
}
|
| 364 |
|
| 365 |
+
function CoachTab({ result }) {
|
| 366 |
+
if (!result)
|
| 367 |
+
return h(
|
| 368 |
+
"section",
|
| 369 |
+
{ className: "summary" },
|
| 370 |
+
h("h2", null, "Coach summary"),
|
| 371 |
+
h("p", null, "Coach notes appear after analysis."),
|
| 372 |
+
);
|
| 373 |
+
const summary = result.report.coach_summary;
|
| 374 |
+
return h(
|
| 375 |
+
"section",
|
| 376 |
+
{ className: "summary" },
|
| 377 |
+
h("h2", null, "Coach summary"),
|
| 378 |
+
h("p", null, summary.summary),
|
| 379 |
+
h(
|
| 380 |
+
"div",
|
| 381 |
+
{ className: "note-grid" },
|
| 382 |
+
h(NoteList, { title: "Main findings", items: summary.main_findings }),
|
| 383 |
+
h(NoteList, { title: "Top fixes", items: summary.top_fixes }),
|
| 384 |
+
h(NoteList, {
|
| 385 |
+
title: "Confidence notes",
|
| 386 |
+
items: summary.confidence_notes,
|
| 387 |
+
}),
|
| 388 |
+
),
|
| 389 |
+
h(
|
| 390 |
+
"article",
|
| 391 |
+
{ className: "note full-note" },
|
| 392 |
+
h("h3", null, "Variation context"),
|
| 393 |
+
h("p", null, summary.variation_explanation),
|
| 394 |
+
),
|
| 395 |
+
);
|
| 396 |
+
}
|
| 397 |
+
|
| 398 |
+
function JsonTab({ result }) {
|
| 399 |
+
return h(
|
| 400 |
+
"pre",
|
| 401 |
+
{ className: "json-block" },
|
| 402 |
+
result ? JSON.stringify(result.report, null, 2) : "{}",
|
| 403 |
+
);
|
| 404 |
+
}
|
| 405 |
+
|
| 406 |
+
function ArtifactsTab({ result }) {
|
| 407 |
+
const links = result?.artifact_urls || [];
|
| 408 |
+
return h(
|
| 409 |
+
"section",
|
| 410 |
+
{ className: "artifact-panel" },
|
| 411 |
+
h(
|
| 412 |
+
"div",
|
| 413 |
+
{ className: "artifact-links" },
|
| 414 |
+
links.length
|
| 415 |
+
? links.map((artifact) =>
|
| 416 |
+
h(
|
| 417 |
+
"a",
|
| 418 |
+
{
|
| 419 |
+
className: "artifact-link",
|
| 420 |
+
key: artifact.url,
|
| 421 |
+
href: artifact.url,
|
| 422 |
+
download: artifact.name,
|
| 423 |
+
},
|
| 424 |
+
artifact.name,
|
| 425 |
+
h("span", null, "download"),
|
| 426 |
+
),
|
| 427 |
+
)
|
| 428 |
+
: h("p", null, "Artifacts appear after analysis."),
|
| 429 |
+
),
|
| 430 |
+
);
|
| 431 |
+
}
|
| 432 |
+
|
| 433 |
+
const reportTabs = [
|
| 434 |
+
["summary", "Summary"],
|
| 435 |
+
["metrics", "Metrics"],
|
| 436 |
+
["reps", "Reps"],
|
| 437 |
+
["issues", "Issues"],
|
| 438 |
+
["coach", "Coach"],
|
| 439 |
+
["json", "JSON"],
|
| 440 |
+
["artifacts", "Artifacts"],
|
| 441 |
+
];
|
| 442 |
+
|
| 443 |
+
function ReportPanel({ result, activeTab, onTabChange }) {
|
| 444 |
+
const content = {
|
| 445 |
+
summary: h(SummaryTab, { result }),
|
| 446 |
+
metrics: h(MetricsTab, { result }),
|
| 447 |
+
reps: h(RepsTab, { result }),
|
| 448 |
+
issues: h(IssuesTab, { result }),
|
| 449 |
+
coach: h(CoachTab, { result }),
|
| 450 |
+
json: h(JsonTab, { result }),
|
| 451 |
+
artifacts: h(ArtifactsTab, { result }),
|
| 452 |
+
}[activeTab];
|
| 453 |
+
|
| 454 |
+
return h(
|
| 455 |
+
React.Fragment,
|
| 456 |
+
null,
|
| 457 |
+
h(
|
| 458 |
+
"div",
|
| 459 |
+
{ className: "tabs", role: "tablist", "aria-label": "Report sections" },
|
| 460 |
+
reportTabs.map(([key, name]) =>
|
| 461 |
+
h(
|
| 462 |
+
"button",
|
| 463 |
+
{
|
| 464 |
+
className: `tab${activeTab === key ? " active" : ""}`,
|
| 465 |
+
key,
|
| 466 |
+
onClick: () => onTabChange(key),
|
| 467 |
+
type: "button",
|
| 468 |
+
},
|
| 469 |
+
name,
|
| 470 |
+
),
|
| 471 |
+
),
|
| 472 |
+
),
|
| 473 |
+
content,
|
| 474 |
+
);
|
| 475 |
+
}
|
| 476 |
+
|
| 477 |
function NoteList({ title, items }) {
|
| 478 |
return h(
|
| 479 |
"article",
|
| 480 |
{ className: "note" },
|
| 481 |
h("h3", null, title),
|
| 482 |
+
h(
|
| 483 |
+
"ul",
|
| 484 |
+
null,
|
| 485 |
+
items.map((item) => h("li", { key: item }, item)),
|
| 486 |
+
),
|
| 487 |
);
|
| 488 |
}
|
| 489 |
|
|
|
|
| 497 |
const [equipment, setEquipment] = useState("bodyweight");
|
| 498 |
const [limitations, setLimitations] = useState([]);
|
| 499 |
const [result, setResult] = useState(null);
|
| 500 |
+
const [activeTab, setActiveTab] = useState("summary");
|
| 501 |
const [status, setStatus] = useState("idle");
|
| 502 |
const [error, setError] = useState("");
|
| 503 |
|
| 504 |
+
const previewUrl = useMemo(
|
| 505 |
+
() => (file ? URL.createObjectURL(file) : ""),
|
| 506 |
+
[file],
|
| 507 |
+
);
|
| 508 |
|
| 509 |
useEffect(() => {
|
| 510 |
fetch("/api/config")
|
|
|
|
| 521 |
|
| 522 |
function toggleLimitation(value) {
|
| 523 |
setLimitations((current) =>
|
| 524 |
+
current.includes(value)
|
| 525 |
+
? current.filter((item) => item !== value)
|
| 526 |
+
: [...current, value],
|
| 527 |
);
|
| 528 |
}
|
| 529 |
|
|
|
|
| 543 |
payload.append("equipment", equipment);
|
| 544 |
|
| 545 |
try {
|
| 546 |
+
const response = await fetch("/api/analyze", {
|
| 547 |
+
method: "POST",
|
| 548 |
+
body: payload,
|
| 549 |
+
});
|
| 550 |
const body = await response.json();
|
| 551 |
if (!response.ok) throw new Error(body.detail || "Analysis failed.");
|
| 552 |
setResult(body);
|
|
|
|
| 566 |
h(
|
| 567 |
"div",
|
| 568 |
{ className: "hero-content" },
|
| 569 |
+
h(
|
| 570 |
+
"p",
|
| 571 |
+
{ className: "eyebrow" },
|
| 572 |
+
"Pose intelligence for coached training",
|
| 573 |
+
),
|
| 574 |
h("h1", null, "Pozify"),
|
| 575 |
h("p", null, config.description),
|
| 576 |
),
|
| 577 |
h(
|
| 578 |
"div",
|
| 579 |
{ className: "hero-metrics", "aria-label": "Pipeline highlights" },
|
| 580 |
+
h(
|
| 581 |
+
"div",
|
| 582 |
+
{ className: "metric" },
|
| 583 |
+
h("strong", null, "33"),
|
| 584 |
+
h("span", null, "pose landmarks"),
|
| 585 |
+
),
|
| 586 |
+
h(
|
| 587 |
+
"div",
|
| 588 |
+
{ className: "metric" },
|
| 589 |
+
h("strong", null, "60s"),
|
| 590 |
+
h("span", null, "clip ceiling"),
|
| 591 |
+
),
|
| 592 |
+
h(
|
| 593 |
+
"div",
|
| 594 |
+
{ className: "metric" },
|
| 595 |
+
h("strong", null, "JSON"),
|
| 596 |
+
h("span", null, "audit trail"),
|
| 597 |
+
),
|
| 598 |
),
|
| 599 |
),
|
| 600 |
h(
|
|
|
|
| 606 |
h(
|
| 607 |
"div",
|
| 608 |
{ className: "panel-head" },
|
| 609 |
+
h(
|
| 610 |
+
"div",
|
| 611 |
+
null,
|
| 612 |
+
h("h2", null, "Session setup"),
|
| 613 |
+
h("p", null, "Movement context"),
|
| 614 |
+
),
|
| 615 |
+
h(
|
| 616 |
+
"span",
|
| 617 |
+
{ className: "status-pill" },
|
| 618 |
+
status === "running" ? "Analyzing" : "Ready",
|
| 619 |
+
),
|
| 620 |
),
|
| 621 |
h(
|
| 622 |
"label",
|
|
|
|
| 627 |
onChange: (event) => setFile(event.target.files?.[0] || null),
|
| 628 |
}),
|
| 629 |
previewUrl
|
| 630 |
+
? h("video", {
|
| 631 |
+
className: "dropzone-preview",
|
| 632 |
+
src: previewUrl,
|
| 633 |
+
controls: true,
|
| 634 |
+
})
|
| 635 |
: h(
|
| 636 |
"span",
|
| 637 |
{ className: "dropzone-empty" },
|
|
|
|
| 643 |
h(
|
| 644 |
"div",
|
| 645 |
{ className: "form-grid" },
|
| 646 |
+
h(SelectField, {
|
| 647 |
+
labelText: "Goal",
|
| 648 |
+
value: goal,
|
| 649 |
+
onChange: setGoal,
|
| 650 |
+
options: config.goals,
|
| 651 |
+
}),
|
| 652 |
h(SelectField, {
|
| 653 |
labelText: "Experience",
|
| 654 |
value: experience,
|
|
|
|
| 699 |
),
|
| 700 |
),
|
| 701 |
),
|
| 702 |
+
h(
|
| 703 |
+
"button",
|
| 704 |
+
{ className: "primary", disabled: status === "running" },
|
| 705 |
+
status === "running" ? "Analyzing..." : "Analyze Form",
|
| 706 |
+
),
|
| 707 |
error ? h("div", { className: "error" }, error) : null,
|
| 708 |
),
|
| 709 |
h(
|
|
|
|
| 712 |
h(
|
| 713 |
"div",
|
| 714 |
{ className: "panel-head" },
|
| 715 |
+
h(
|
| 716 |
+
"div",
|
| 717 |
+
null,
|
| 718 |
+
h("h2", null, "Review output"),
|
| 719 |
+
h("p", null, "Annotated movement"),
|
| 720 |
+
),
|
| 721 |
+
result
|
| 722 |
+
? h("span", { className: "status-pill" }, result.run_id)
|
| 723 |
+
: null,
|
| 724 |
),
|
| 725 |
h(
|
| 726 |
"div",
|
| 727 |
{ className: "result-stage" },
|
| 728 |
result?.annotated_video_url
|
| 729 |
+
? h("video", {
|
| 730 |
+
className: "result-video",
|
| 731 |
+
src: result.annotated_video_url,
|
| 732 |
+
controls: true,
|
| 733 |
+
})
|
| 734 |
: h(
|
| 735 |
"div",
|
| 736 |
{ className: "result-empty" },
|
|
|
|
| 740 |
),
|
| 741 |
),
|
| 742 |
),
|
| 743 |
+
h(ReportPanel, { result, activeTab, onTabChange: setActiveTab }),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 744 |
);
|
| 745 |
}
|
| 746 |
|
web/styles.css
CHANGED
|
@@ -378,6 +378,13 @@ input[type="text"]:focus,
|
|
| 378 |
margin: 16px 0;
|
| 379 |
}
|
| 380 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 381 |
.stat {
|
| 382 |
border: 1px solid rgba(37, 44, 31, 0.12);
|
| 383 |
border-radius: 8px;
|
|
@@ -425,6 +432,106 @@ input[type="text"]:focus,
|
|
| 425 |
color: #3e4438;
|
| 426 |
}
|
| 427 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 428 |
.quality-list {
|
| 429 |
display: flex;
|
| 430 |
flex-wrap: wrap;
|
|
@@ -581,7 +688,10 @@ input[type="text"]:focus,
|
|
| 581 |
@media (max-width: 980px) {
|
| 582 |
.workspace,
|
| 583 |
.note-grid,
|
| 584 |
-
.stat-grid
|
|
|
|
|
|
|
|
|
|
| 585 |
grid-template-columns: 1fr;
|
| 586 |
}
|
| 587 |
}
|
|
|
|
| 378 |
margin: 16px 0;
|
| 379 |
}
|
| 380 |
|
| 381 |
+
.metric-grid {
|
| 382 |
+
display: grid;
|
| 383 |
+
grid-template-columns: repeat(4, minmax(0, 1fr));
|
| 384 |
+
gap: 10px;
|
| 385 |
+
margin: 16px 0;
|
| 386 |
+
}
|
| 387 |
+
|
| 388 |
.stat {
|
| 389 |
border: 1px solid rgba(37, 44, 31, 0.12);
|
| 390 |
border-radius: 8px;
|
|
|
|
| 432 |
color: #3e4438;
|
| 433 |
}
|
| 434 |
|
| 435 |
+
.full-note {
|
| 436 |
+
margin-top: 12px;
|
| 437 |
+
}
|
| 438 |
+
|
| 439 |
+
.full-note p {
|
| 440 |
+
margin: 0;
|
| 441 |
+
}
|
| 442 |
+
|
| 443 |
+
.table-wrap {
|
| 444 |
+
overflow: auto;
|
| 445 |
+
border: 1px solid rgba(37, 44, 31, 0.12);
|
| 446 |
+
border-radius: 8px;
|
| 447 |
+
background: rgba(255, 253, 247, 0.68);
|
| 448 |
+
}
|
| 449 |
+
|
| 450 |
+
table {
|
| 451 |
+
width: 100%;
|
| 452 |
+
border-collapse: collapse;
|
| 453 |
+
min-width: 620px;
|
| 454 |
+
}
|
| 455 |
+
|
| 456 |
+
th,
|
| 457 |
+
td {
|
| 458 |
+
border-bottom: 1px solid rgba(37, 44, 31, 0.1);
|
| 459 |
+
padding: 11px 12px;
|
| 460 |
+
text-align: left;
|
| 461 |
+
}
|
| 462 |
+
|
| 463 |
+
th {
|
| 464 |
+
color: var(--forest);
|
| 465 |
+
font-size: 0.78rem;
|
| 466 |
+
text-transform: uppercase;
|
| 467 |
+
}
|
| 468 |
+
|
| 469 |
+
td {
|
| 470 |
+
color: #3e4438;
|
| 471 |
+
}
|
| 472 |
+
|
| 473 |
+
.rep-grid,
|
| 474 |
+
.issue-card-grid {
|
| 475 |
+
display: grid;
|
| 476 |
+
grid-template-columns: repeat(3, minmax(0, 1fr));
|
| 477 |
+
gap: 12px;
|
| 478 |
+
margin-top: 14px;
|
| 479 |
+
}
|
| 480 |
+
|
| 481 |
+
.rep-card,
|
| 482 |
+
.issue-card {
|
| 483 |
+
border: 1px solid rgba(37, 44, 31, 0.12);
|
| 484 |
+
border-radius: 8px;
|
| 485 |
+
background: rgba(255, 253, 247, 0.66);
|
| 486 |
+
}
|
| 487 |
+
|
| 488 |
+
.rep-card {
|
| 489 |
+
display: grid;
|
| 490 |
+
gap: 6px;
|
| 491 |
+
padding: 14px;
|
| 492 |
+
}
|
| 493 |
+
|
| 494 |
+
.rep-card strong {
|
| 495 |
+
color: var(--forest-deep);
|
| 496 |
+
}
|
| 497 |
+
|
| 498 |
+
.rep-card span {
|
| 499 |
+
color: var(--muted);
|
| 500 |
+
font-size: 0.88rem;
|
| 501 |
+
}
|
| 502 |
+
|
| 503 |
+
.issue-card {
|
| 504 |
+
overflow: hidden;
|
| 505 |
+
}
|
| 506 |
+
|
| 507 |
+
.issue-card .timeline-main,
|
| 508 |
+
.issue-card .timeline-meta {
|
| 509 |
+
padding: 10px 12px;
|
| 510 |
+
}
|
| 511 |
+
|
| 512 |
+
.issue-card .timeline-meta {
|
| 513 |
+
padding-top: 0;
|
| 514 |
+
}
|
| 515 |
+
|
| 516 |
+
.issue-thumb,
|
| 517 |
+
.issue-clip {
|
| 518 |
+
display: block;
|
| 519 |
+
width: 100%;
|
| 520 |
+
aspect-ratio: 16 / 10;
|
| 521 |
+
object-fit: cover;
|
| 522 |
+
background: #171815;
|
| 523 |
+
}
|
| 524 |
+
|
| 525 |
+
.issue-clip {
|
| 526 |
+
color: var(--paper);
|
| 527 |
+
}
|
| 528 |
+
|
| 529 |
+
.issue-thumb.empty {
|
| 530 |
+
display: grid;
|
| 531 |
+
color: rgba(255, 249, 236, 0.62);
|
| 532 |
+
place-items: center;
|
| 533 |
+
}
|
| 534 |
+
|
| 535 |
.quality-list {
|
| 536 |
display: flex;
|
| 537 |
flex-wrap: wrap;
|
|
|
|
| 688 |
@media (max-width: 980px) {
|
| 689 |
.workspace,
|
| 690 |
.note-grid,
|
| 691 |
+
.stat-grid,
|
| 692 |
+
.metric-grid,
|
| 693 |
+
.rep-grid,
|
| 694 |
+
.issue-card-grid {
|
| 695 |
grid-template-columns: 1fr;
|
| 696 |
}
|
| 697 |
}
|