Spaces:
Running on Zero
Running on Zero
feat(renderer): generate annotated pose videos
Browse files- app.py +3 -1
- src/pozify/pipeline.py +10 -7
- src/pozify/steps/annotated_renderer.py +165 -11
- tests/test_annotated_renderer.py +100 -0
- tests/test_pipeline_contracts.py +0 -1
app.py
CHANGED
|
@@ -92,6 +92,8 @@ def analyze_video(
|
|
| 92 |
- **Exercise:** {report["exercise"]["exercise"]} ({report["exercise"]["confidence"]:.0%} confidence)
|
| 93 |
- **Variation:** {report["variation"]["detected_variation"]} ({report["variation"]["variation_confidence"]:.0%} confidence)
|
| 94 |
- **Reps:** {len(report["reps"]["reps"])}
|
|
|
|
|
|
|
| 95 |
- **Main finding:** {summary["main_findings"][0] if summary["main_findings"] else "No major issue detected"}
|
| 96 |
- **Run ID:** `{report["run_id"]}`
|
| 97 |
|
|
@@ -162,7 +164,7 @@ with gr.Blocks(title="Pozify") as demo:
|
|
| 162 |
run_button = gr.Button("Analyze", variant="primary")
|
| 163 |
|
| 164 |
with gr.Column(scale=2):
|
| 165 |
-
annotated_video = gr.Video(label="Annotated video
|
| 166 |
summary_md = gr.Markdown(label="Summary")
|
| 167 |
|
| 168 |
with gr.Tab("Final Report JSON"):
|
|
|
|
| 92 |
- **Exercise:** {report["exercise"]["exercise"]} ({report["exercise"]["confidence"]:.0%} confidence)
|
| 93 |
- **Variation:** {report["variation"]["detected_variation"]} ({report["variation"]["variation_confidence"]:.0%} confidence)
|
| 94 |
- **Reps:** {len(report["reps"]["reps"])}
|
| 95 |
+
- **Analysis mode:** {report["artifacts"].get("analysis_mode", "unknown")}
|
| 96 |
+
- **Pose source:** {report["artifacts"].get("pose_source", "unknown")}
|
| 97 |
- **Main finding:** {summary["main_findings"][0] if summary["main_findings"] else "No major issue detected"}
|
| 98 |
- **Run ID:** `{report["run_id"]}`
|
| 99 |
|
|
|
|
| 164 |
run_button = gr.Button("Analyze", variant="primary")
|
| 165 |
|
| 166 |
with gr.Column(scale=2):
|
| 167 |
+
annotated_video = gr.Video(label="Annotated video")
|
| 168 |
summary_md = gr.Markdown(label="Summary")
|
| 169 |
|
| 170 |
with gr.Tab("Final Report JSON"):
|
src/pozify/pipeline.py
CHANGED
|
@@ -86,14 +86,14 @@ def run_pipeline(
|
|
| 86 |
issues = issue_marker.run(classification, reps, analysis, variation)
|
| 87 |
write_artifact("issue_markers.json", issues)
|
| 88 |
|
| 89 |
-
annotated_video_path = annotated_renderer.run(manifest, issues, run_dir)
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
}
|
| 96 |
)
|
|
|
|
| 97 |
|
| 98 |
summary = coach_summary.run(profile, classification, reps, analysis, variation, issues)
|
| 99 |
write_artifact("coach_summary.json", summary)
|
|
@@ -115,6 +115,9 @@ def run_pipeline(
|
|
| 115 |
"artifacts": {
|
| 116 |
"run_dir": str(run_dir),
|
| 117 |
"annotated_video_path": annotated_video_path,
|
|
|
|
|
|
|
|
|
|
| 118 |
},
|
| 119 |
}
|
| 120 |
write_artifact("final_report.json", final_report)
|
|
|
|
| 86 |
issues = issue_marker.run(classification, reps, analysis, variation)
|
| 87 |
write_artifact("issue_markers.json", issues)
|
| 88 |
|
| 89 |
+
annotated_video_path = annotated_renderer.run(manifest, cleaned_pose_sequence, reps, issues, run_dir)
|
| 90 |
+
|
| 91 |
+
pose_source = (
|
| 92 |
+
cleaned_pose_sequence.frames[0].pose_quality.get("source")
|
| 93 |
+
if cleaned_pose_sequence.frames
|
| 94 |
+
else "none"
|
|
|
|
| 95 |
)
|
| 96 |
+
analysis_mode = "mock" if mock_mode else "real"
|
| 97 |
|
| 98 |
summary = coach_summary.run(profile, classification, reps, analysis, variation, issues)
|
| 99 |
write_artifact("coach_summary.json", summary)
|
|
|
|
| 115 |
"artifacts": {
|
| 116 |
"run_dir": str(run_dir),
|
| 117 |
"annotated_video_path": annotated_video_path,
|
| 118 |
+
"rep_debug_path": str(run_dir / "rep_debug.json"),
|
| 119 |
+
"analysis_mode": analysis_mode,
|
| 120 |
+
"pose_source": pose_source,
|
| 121 |
},
|
| 122 |
}
|
| 123 |
write_artifact("final_report.json", final_report)
|
src/pozify/steps/annotated_renderer.py
CHANGED
|
@@ -1,19 +1,173 @@
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
from pathlib import Path
|
|
|
|
| 4 |
|
| 5 |
-
|
| 6 |
|
|
|
|
| 7 |
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
(
|
| 15 |
-
|
| 16 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
)
|
| 18 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
|
|
|
|
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
from pathlib import Path
|
| 4 |
+
from typing import Any
|
| 5 |
|
| 6 |
+
import cv2
|
| 7 |
|
| 8 |
+
from pozify.contracts import IssueMarkers, PoseSequence, Reps, VideoManifest
|
| 9 |
|
| 10 |
+
|
| 11 |
+
SKELETON_EDGES = [
|
| 12 |
+
("left_shoulder", "right_shoulder"),
|
| 13 |
+
("left_hip", "right_hip"),
|
| 14 |
+
("left_shoulder", "left_elbow"),
|
| 15 |
+
("left_elbow", "left_wrist"),
|
| 16 |
+
("right_shoulder", "right_elbow"),
|
| 17 |
+
("right_elbow", "right_wrist"),
|
| 18 |
+
("left_shoulder", "left_hip"),
|
| 19 |
+
("right_shoulder", "right_hip"),
|
| 20 |
+
("left_hip", "left_knee"),
|
| 21 |
+
("left_knee", "left_ankle"),
|
| 22 |
+
("right_hip", "right_knee"),
|
| 23 |
+
("right_knee", "right_ankle"),
|
| 24 |
+
]
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def _frame_landmark_points(
|
| 28 |
+
frame_landmarks: dict[str, dict[str, float]],
|
| 29 |
+
width: int,
|
| 30 |
+
height: int,
|
| 31 |
+
) -> dict[str, tuple[int, int]]:
|
| 32 |
+
points: dict[str, tuple[int, int]] = {}
|
| 33 |
+
for name, values in frame_landmarks.items():
|
| 34 |
+
x = values.get("x")
|
| 35 |
+
y = values.get("y")
|
| 36 |
+
if x is None or y is None:
|
| 37 |
+
continue
|
| 38 |
+
points[name] = (int(round(x * width)), int(round(y * height)))
|
| 39 |
+
return points
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def _draw_pose(frame: Any, points: dict[str, tuple[int, int]]) -> None:
|
| 43 |
+
for start_name, end_name in SKELETON_EDGES:
|
| 44 |
+
start = points.get(start_name)
|
| 45 |
+
end = points.get(end_name)
|
| 46 |
+
if start is None or end is None:
|
| 47 |
+
continue
|
| 48 |
+
cv2.line(frame, start, end, (90, 220, 90), 2)
|
| 49 |
+
|
| 50 |
+
for point in points.values():
|
| 51 |
+
cv2.circle(frame, point, 3, (255, 240, 40), -1)
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def _rep_boundaries(reps: Reps) -> dict[int, list[str]]:
|
| 55 |
+
boundaries: dict[int, list[str]] = {}
|
| 56 |
+
for rep in reps.reps:
|
| 57 |
+
boundaries.setdefault(rep.start_frame, []).append(f"rep {rep.rep_id} start")
|
| 58 |
+
boundaries.setdefault(rep.mid_frame, []).append(f"rep {rep.rep_id} mid")
|
| 59 |
+
boundaries.setdefault(rep.end_frame, []).append(f"rep {rep.rep_id} end")
|
| 60 |
+
return boundaries
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def _draw_overlays(
|
| 64 |
+
frame: Any,
|
| 65 |
+
frame_index: int,
|
| 66 |
+
rep_count: int,
|
| 67 |
+
issue_count: int,
|
| 68 |
+
boundary_labels: dict[int, list[str]],
|
| 69 |
+
) -> None:
|
| 70 |
+
cv2.putText(
|
| 71 |
+
frame,
|
| 72 |
+
f"Reps detected: {rep_count}",
|
| 73 |
+
(16, 28),
|
| 74 |
+
cv2.FONT_HERSHEY_SIMPLEX,
|
| 75 |
+
0.8,
|
| 76 |
+
(255, 255, 255),
|
| 77 |
+
2,
|
| 78 |
+
cv2.LINE_AA,
|
| 79 |
+
)
|
| 80 |
+
cv2.putText(
|
| 81 |
+
frame,
|
| 82 |
+
f"Issues: {issue_count}",
|
| 83 |
+
(16, 58),
|
| 84 |
+
cv2.FONT_HERSHEY_SIMPLEX,
|
| 85 |
+
0.7,
|
| 86 |
+
(200, 230, 255),
|
| 87 |
+
2,
|
| 88 |
+
cv2.LINE_AA,
|
| 89 |
)
|
| 90 |
+
labels = boundary_labels.get(frame_index, [])
|
| 91 |
+
for offset, label in enumerate(labels):
|
| 92 |
+
cv2.putText(
|
| 93 |
+
frame,
|
| 94 |
+
label,
|
| 95 |
+
(16, 96 + offset * 28),
|
| 96 |
+
cv2.FONT_HERSHEY_SIMPLEX,
|
| 97 |
+
0.75,
|
| 98 |
+
(80, 180, 255),
|
| 99 |
+
2,
|
| 100 |
+
cv2.LINE_AA,
|
| 101 |
+
)
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def run(
|
| 105 |
+
manifest: VideoManifest,
|
| 106 |
+
pose_sequence: PoseSequence,
|
| 107 |
+
reps: Reps,
|
| 108 |
+
issues: IssueMarkers,
|
| 109 |
+
run_dir: Path,
|
| 110 |
+
) -> str | None:
|
| 111 |
+
if not manifest.analysis_allowed or not manifest.video_path:
|
| 112 |
+
return manifest.video_path
|
| 113 |
+
|
| 114 |
+
capture = cv2.VideoCapture(manifest.video_path)
|
| 115 |
+
if not capture.isOpened():
|
| 116 |
+
capture.release()
|
| 117 |
+
return manifest.video_path
|
| 118 |
+
|
| 119 |
+
fps = manifest.fps if manifest.fps > 0 else 30.0
|
| 120 |
+
width = manifest.width or int(capture.get(cv2.CAP_PROP_FRAME_WIDTH) or 0)
|
| 121 |
+
height = manifest.height or int(capture.get(cv2.CAP_PROP_FRAME_HEIGHT) or 0)
|
| 122 |
+
if width <= 0 or height <= 0:
|
| 123 |
+
capture.release()
|
| 124 |
+
return manifest.video_path
|
| 125 |
+
|
| 126 |
+
output_path = run_dir / "annotated_video.mp4"
|
| 127 |
+
writer = cv2.VideoWriter(
|
| 128 |
+
str(output_path),
|
| 129 |
+
cv2.VideoWriter_fourcc(*"mp4v"),
|
| 130 |
+
fps,
|
| 131 |
+
(width, height),
|
| 132 |
+
)
|
| 133 |
+
if not writer.isOpened():
|
| 134 |
+
capture.release()
|
| 135 |
+
return manifest.video_path
|
| 136 |
+
|
| 137 |
+
pose_by_frame = {frame.frame_index: frame for frame in pose_sequence.frames}
|
| 138 |
+
ordered_pose_frames = sorted(pose_sequence.frames, key=lambda frame: frame.frame_index)
|
| 139 |
+
pose_cursor = 0
|
| 140 |
+
last_pose_frame = None
|
| 141 |
+
boundary_labels = _rep_boundaries(reps)
|
| 142 |
+
|
| 143 |
+
try:
|
| 144 |
+
frame_index = 0
|
| 145 |
+
while True:
|
| 146 |
+
ok, frame = capture.read()
|
| 147 |
+
if not ok or frame is None:
|
| 148 |
+
break
|
| 149 |
+
|
| 150 |
+
if pose_cursor < len(ordered_pose_frames):
|
| 151 |
+
while (
|
| 152 |
+
pose_cursor + 1 < len(ordered_pose_frames)
|
| 153 |
+
and ordered_pose_frames[pose_cursor + 1].frame_index <= frame_index
|
| 154 |
+
):
|
| 155 |
+
pose_cursor += 1
|
| 156 |
+
candidate = ordered_pose_frames[pose_cursor]
|
| 157 |
+
if candidate.frame_index <= frame_index:
|
| 158 |
+
last_pose_frame = candidate
|
| 159 |
+
|
| 160 |
+
exact_pose = pose_by_frame.get(frame_index)
|
| 161 |
+
active_pose = exact_pose or last_pose_frame
|
| 162 |
+
if active_pose is not None and active_pose.landmarks:
|
| 163 |
+
points = _frame_landmark_points(active_pose.landmarks, width, height)
|
| 164 |
+
_draw_pose(frame, points)
|
| 165 |
+
|
| 166 |
+
_draw_overlays(frame, frame_index, len(reps.reps), len(issues.issues), boundary_labels)
|
| 167 |
+
writer.write(frame)
|
| 168 |
+
frame_index += 1
|
| 169 |
+
finally:
|
| 170 |
+
writer.release()
|
| 171 |
+
capture.release()
|
| 172 |
|
| 173 |
+
return str(output_path)
|
tests/test_annotated_renderer.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
import sys
|
| 5 |
+
import tempfile
|
| 6 |
+
import unittest
|
| 7 |
+
|
| 8 |
+
import cv2
|
| 9 |
+
import numpy as np
|
| 10 |
+
|
| 11 |
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
| 12 |
+
|
| 13 |
+
from pozify.contracts import IssueMarkers, PoseFrame, PoseSequence, Rep, Reps, VideoManifest
|
| 14 |
+
from pozify.steps import annotated_renderer
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def _frame(frame_index: int) -> PoseFrame:
|
| 18 |
+
landmarks = {
|
| 19 |
+
"left_shoulder": {"x": 0.35, "y": 0.3},
|
| 20 |
+
"right_shoulder": {"x": 0.65, "y": 0.3},
|
| 21 |
+
"left_elbow": {"x": 0.3, "y": 0.45},
|
| 22 |
+
"right_elbow": {"x": 0.7, "y": 0.45},
|
| 23 |
+
"left_wrist": {"x": 0.28, "y": 0.6},
|
| 24 |
+
"right_wrist": {"x": 0.72, "y": 0.6},
|
| 25 |
+
"left_hip": {"x": 0.42, "y": 0.55},
|
| 26 |
+
"right_hip": {"x": 0.58, "y": 0.55},
|
| 27 |
+
"left_knee": {"x": 0.42, "y": 0.75},
|
| 28 |
+
"right_knee": {"x": 0.58, "y": 0.75},
|
| 29 |
+
"left_ankle": {"x": 0.42, "y": 0.92},
|
| 30 |
+
"right_ankle": {"x": 0.58, "y": 0.92},
|
| 31 |
+
}
|
| 32 |
+
return PoseFrame(
|
| 33 |
+
frame_index=frame_index,
|
| 34 |
+
timestamp_sec=round(frame_index / 30.0, 3),
|
| 35 |
+
landmarks=landmarks,
|
| 36 |
+
world_landmarks={},
|
| 37 |
+
pose_quality={"source": "fake_pose", "mean_visibility": 0.95},
|
| 38 |
+
)
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
class AnnotatedRendererTests(unittest.TestCase):
|
| 42 |
+
def setUp(self) -> None:
|
| 43 |
+
self.temp_dir = tempfile.TemporaryDirectory()
|
| 44 |
+
|
| 45 |
+
def tearDown(self) -> None:
|
| 46 |
+
self.temp_dir.cleanup()
|
| 47 |
+
|
| 48 |
+
def _write_video(self, frame_count: int = 6) -> Path:
|
| 49 |
+
path = Path(self.temp_dir.name) / "input.mp4"
|
| 50 |
+
writer = cv2.VideoWriter(str(path), cv2.VideoWriter_fourcc(*"mp4v"), 30.0, (320, 240))
|
| 51 |
+
self.assertTrue(writer.isOpened())
|
| 52 |
+
for frame_index in range(frame_count):
|
| 53 |
+
frame = np.full((240, 320, 3), 90 + frame_index, dtype=np.uint8)
|
| 54 |
+
writer.write(frame)
|
| 55 |
+
writer.release()
|
| 56 |
+
return path
|
| 57 |
+
|
| 58 |
+
def test_renderer_writes_annotated_video(self) -> None:
|
| 59 |
+
video_path = self._write_video()
|
| 60 |
+
manifest = VideoManifest(
|
| 61 |
+
video_path=str(video_path),
|
| 62 |
+
fps=30.0,
|
| 63 |
+
duration_sec=0.2,
|
| 64 |
+
total_frames=6,
|
| 65 |
+
sampled_frames=6,
|
| 66 |
+
width=320,
|
| 67 |
+
height=240,
|
| 68 |
+
codec="mp4v",
|
| 69 |
+
container="mp4",
|
| 70 |
+
brightness_mean=100.0,
|
| 71 |
+
blur_laplacian_var=100.0,
|
| 72 |
+
quality_warnings=[],
|
| 73 |
+
analysis_allowed=True,
|
| 74 |
+
)
|
| 75 |
+
pose_sequence = PoseSequence(
|
| 76 |
+
frames=[_frame(index) for index in range(6)],
|
| 77 |
+
normalized=True,
|
| 78 |
+
smoothing_method="exponential_smoothing",
|
| 79 |
+
pose_valid_ratio=1.0,
|
| 80 |
+
)
|
| 81 |
+
reps = Reps(
|
| 82 |
+
exercise="push_up",
|
| 83 |
+
reps=[Rep(1, 0, 2, 5, 0.0, 0.067, 0.167)],
|
| 84 |
+
partial_reps=[],
|
| 85 |
+
)
|
| 86 |
+
|
| 87 |
+
output_path = annotated_renderer.run(
|
| 88 |
+
manifest,
|
| 89 |
+
pose_sequence,
|
| 90 |
+
reps,
|
| 91 |
+
IssueMarkers(issues=[]),
|
| 92 |
+
Path(self.temp_dir.name),
|
| 93 |
+
)
|
| 94 |
+
|
| 95 |
+
self.assertIsNotNone(output_path)
|
| 96 |
+
self.assertTrue(Path(str(output_path)).exists())
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
if __name__ == "__main__":
|
| 100 |
+
unittest.main()
|
tests/test_pipeline_contracts.py
CHANGED
|
@@ -149,7 +149,6 @@ class PipelineContractTests(unittest.TestCase):
|
|
| 149 |
"rep_analysis.json",
|
| 150 |
"variation.json",
|
| 151 |
"issue_markers.json",
|
| 152 |
-
"annotated_video_placeholder.json",
|
| 153 |
"coach_summary.json",
|
| 154 |
"verification.json",
|
| 155 |
"final_report.json",
|
|
|
|
| 149 |
"rep_analysis.json",
|
| 150 |
"variation.json",
|
| 151 |
"issue_markers.json",
|
|
|
|
| 152 |
"coach_summary.json",
|
| 153 |
"verification.json",
|
| 154 |
"final_report.json",
|