tthhanh commited on
Commit
d5fd424
·
1 Parent(s): 5b24a5e

feat(renderer): generate annotated pose videos

Browse files
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 placeholder")
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
- artifact_index.append(
91
- {
92
- "name": "annotated_video_placeholder.json",
93
- "path": str(run_dir / "annotated_video_placeholder.json"),
94
- "contract": "renderer_placeholder",
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
- from pozify.contracts import IssueMarkers, VideoManifest
6
 
 
7
 
8
- def run(manifest: VideoManifest, issues: IssueMarkers, run_dir: Path) -> str | None:
9
- placeholder = {
10
- "status": "mock_renderer",
11
- "message": "Annotated renderer is not implemented yet. Returning original video path.",
12
- "issue_count": len(issues.issues),
13
- }
14
- (run_dir / "annotated_video_placeholder.json").write_text(
15
- __import__("json").dumps(placeholder, ensure_ascii=False, indent=2),
16
- encoding="utf-8",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
  )
18
- return manifest.video_path
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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",