| """ |
| PoseVisualizer β annotated overlay video with skeleton, trails, velocity arrows. |
| |
| Input: IngestResult + Pose2DResult |
| Output: .mp4 path (or None on failure/empty layers) |
| Failure: returns None, never raises. |
| """ |
| from __future__ import annotations |
|
|
| import colorsys |
| import logging |
| import math |
| import tempfile |
| from collections import deque |
|
|
| import cv2 |
| import numpy as np |
|
|
| logger = logging.getLogger(__name__) |
|
|
| |
|
|
| COCO_KEYPOINTS = [ |
| "nose", "left_eye", "right_eye", "left_ear", "right_ear", |
| "left_shoulder", "right_shoulder", "left_elbow", "right_elbow", |
| "left_wrist", "right_wrist", "left_hip", "right_hip", |
| "left_knee", "right_knee", "left_ankle", "right_ankle", |
| ] |
|
|
| COCO_SKELETON = [ |
| (0, 1), (0, 2), (1, 3), (2, 4), |
| (5, 6), (5, 7), (7, 9), (6, 8), (8, 10), |
| (5, 11), (6, 12), (11, 12), |
| (11, 13), (13, 15), (12, 14), (14, 16), |
| ] |
|
|
| TRAIL_LENGTH = 10 |
| MAX_ARROW_PX = 40 |
| CONF_THRESHOLD = 0.3 |
|
|
|
|
| |
|
|
| class SimpleKalmanFilter: |
| """4-state Kalman filter (x, y, vx, vy) for joint tracking.""" |
|
|
| def __init__(self, process_noise: float = 0.01, measurement_noise: float = 0.1): |
| self.is_initialized = False |
| self.state = np.zeros(4) |
| self.cov = np.eye(4) * 0.1 |
| self.Q = np.eye(4) * process_noise |
| self.R = np.eye(2) * measurement_noise |
| self.H = np.array([[1, 0, 0, 0], [0, 1, 0, 0]], dtype=float) |
|
|
| def predict(self, dt: float = 1.0): |
| F = np.array([[1, 0, dt, 0], [0, 1, 0, dt], [0, 0, 1, 0], [0, 0, 0, 1]], dtype=float) |
| self.state = F @ self.state |
| self.cov = F @ self.cov @ F.T + self.Q |
|
|
| def update(self, x: float, y: float): |
| z = np.array([x, y]) |
| if not self.is_initialized: |
| self.state[:2] = z |
| self.is_initialized = True |
| return |
| S = self.H @ self.cov @ self.H.T + self.R |
| K = self.cov @ self.H.T @ np.linalg.inv(S) |
| self.state = self.state + K @ (z - self.H @ self.state) |
| self.cov = (np.eye(4) - K @ self.H) @ self.cov |
|
|
| def velocity_magnitude(self) -> float: |
| vx, vy = self.state[2], self.state[3] |
| return math.sqrt(vx * vx + vy * vy) |
|
|
| def velocity_vector(self) -> tuple[float, float]: |
| return float(self.state[2]), float(self.state[3]) |
|
|
|
|
| |
|
|
| def compute_joint_velocity( |
| keypoints_per_frame: list[dict], |
| fps: float, |
| ) -> dict[int, list[float]]: |
| """ |
| Compute Kalman-filtered per-joint speed (px/s) for each frame. |
| |
| Returns dict[joint_idx, [speed_frame0, ...]] for all 17 COCO joints. |
| Missing/low-confidence keypoints yield speed=0.0 for that frame. |
| """ |
| dt = 1.0 / fps if fps > 0 else 1.0 |
| filters: dict[int, SimpleKalmanFilter] = {j: SimpleKalmanFilter() for j in range(17)} |
| result: dict[int, list[float]] = {j: [] for j in range(17)} |
|
|
| for frame_kps in keypoints_per_frame: |
| for j in range(17): |
| kf = filters[j] |
| kp = frame_kps.get(j) |
| kf.predict(dt) |
| if kp and kp.get("conf", 0.0) >= CONF_THRESHOLD: |
| kf.update(kp["x"], kp["y"]) |
| speed = kf.velocity_magnitude() |
| else: |
| speed = 0.0 |
| result[j].append(speed) |
|
|
| return result |
|
|
|
|
| |
|
|
| def _conf_to_bgr(conf: float) -> tuple[int, int, int]: |
| """Map confidence 0β1 to BGR color redβgreen via HSV.""" |
| hue = conf * 120.0 / 360.0 |
| r, g, b = colorsys.hsv_to_rgb(hue, 1.0, 1.0) |
| return (int(b * 255), int(g * 255), int(r * 255)) |
|
|
|
|
| |
|
|
| class PoseVisualizer: |
| """Renders skeleton, trails, and velocity arrows onto video frames.""" |
|
|
| def __init__(self): |
| self.last_velocities: dict[int, list[float]] = {} |
|
|
| |
|
|
| def _draw_skeleton(self, frame: np.ndarray, kps: dict) -> np.ndarray: |
| """Draw COCO-17 bones (white) and joints (confidence-colored) onto frame.""" |
| visible = {j: kp for j, kp in kps.items() if kp.get("conf", 0.0) >= CONF_THRESHOLD} |
|
|
| |
| for j1, j2 in COCO_SKELETON: |
| if j1 in visible and j2 in visible: |
| p1 = (int(visible[j1]["x"]), int(visible[j1]["y"])) |
| p2 = (int(visible[j2]["x"]), int(visible[j2]["y"])) |
| cv2.line(frame, p1, p2, (255, 255, 255), 2) |
|
|
| |
| for j, kp in visible.items(): |
| pt = (int(kp["x"]), int(kp["y"])) |
| color = _conf_to_bgr(kp["conf"]) |
| cv2.circle(frame, pt, 4, color, -1) |
| cv2.circle(frame, pt, 5, (255, 255, 255), 1) |
|
|
| return frame |
|
|
| |
|
|
| def _draw_trails(self, frame: np.ndarray, trail_history: dict) -> np.ndarray: |
| """Draw fading motion trails for each joint.""" |
| for joint_idx, trail in trail_history.items(): |
| pts = list(trail) |
| if len(pts) < 2: |
| continue |
| for i in range(1, len(pts)): |
| alpha = i / len(pts) |
| brightness = int(255 * alpha) |
| color = (brightness, brightness, brightness) |
| thickness = max(1, int(3 * alpha)) |
| p1 = (int(pts[i - 1][0]), int(pts[i - 1][1])) |
| p2 = (int(pts[i][0]), int(pts[i][1])) |
| cv2.line(frame, p1, p2, color, thickness) |
| return frame |
|
|
| |
|
|
| def _draw_velocity_arrows( |
| self, |
| frame: np.ndarray, |
| kps: dict, |
| prev_kps: dict | None, |
| velocities: dict[int, list[float]], |
| frame_idx: int, |
| ) -> np.ndarray: |
| """Draw per-joint velocity arrows scaled by speed.""" |
| if prev_kps is None: |
| return frame |
|
|
| all_speeds = [velocities[j][frame_idx] for j in range(17) if frame_idx < len(velocities.get(j, []))] |
| peak = max(all_speeds) if all_speeds else 1.0 |
| if peak == 0.0: |
| return frame |
|
|
| for j in range(17): |
| kp = kps.get(j) |
| pk = prev_kps.get(j) |
| if not kp or not pk: |
| continue |
| if kp.get("conf", 0.0) < CONF_THRESHOLD: |
| continue |
| speeds = velocities.get(j, []) |
| if frame_idx >= len(speeds): |
| continue |
| speed = speeds[frame_idx] |
| if speed == 0.0: |
| continue |
|
|
| dx = kp["x"] - pk["x"] |
| dy = kp["y"] - pk["y"] |
| mag = math.sqrt(dx * dx + dy * dy) |
| if mag < 1e-6: |
| continue |
|
|
| length = min(speed / peak * MAX_ARROW_PX, MAX_ARROW_PX) |
| nx, ny = dx / mag, dy / mag |
| start = (int(kp["x"]), int(kp["y"])) |
| end = (int(kp["x"] + nx * length), int(kp["y"] + ny * length)) |
|
|
| ratio = speed / peak |
| if ratio < 0.33: |
| color = (0, 200, 0) |
| elif ratio < 0.66: |
| color = (0, 140, 255) |
| else: |
| color = (0, 0, 255) |
|
|
| cv2.arrowedLine(frame, start, end, color, 2, tipLength=0.35) |
|
|
| return frame |
|
|
| |
|
|
| def render_video( |
| self, |
| ingest, |
| pose2d, |
| layers: set[str], |
| output_path: str, |
| ) -> str | None: |
| """ |
| Render annotated video. Returns output_path on success, None otherwise. |
| layers: subset of {"skeleton", "trails", "velocity_arrows"} |
| """ |
| if not layers: |
| return None |
|
|
| if not any(pose2d.keypoints): |
| return None |
|
|
| try: |
| velocities = compute_joint_velocity(pose2d.keypoints, ingest.fps) |
| self.last_velocities = velocities |
|
|
| frames = ingest.frames |
| orig_h, orig_w = frames[0].shape[:2] |
| fps = ingest.fps or 30.0 |
|
|
| |
| max_w = 1280 |
| if orig_w > max_w: |
| scale = max_w / orig_w |
| out_w = max_w |
| out_h = int(orig_h * scale) |
| else: |
| scale = 1.0 |
| out_w, out_h = orig_w, orig_h |
|
|
| |
| def _scale_kps(kps: dict) -> dict: |
| if scale == 1.0: |
| return kps |
| return { |
| j: {**kp, "x": kp["x"] * scale, "y": kp["y"] * scale} |
| for j, kp in kps.items() |
| } |
|
|
| scaled_keypoints = [_scale_kps(k) for k in pose2d.keypoints] |
|
|
| |
| |
| |
| writer = None |
| used_fourcc = None |
| for _tag in ("avc1", "mp4v"): |
| candidate = cv2.VideoWriter( |
| output_path, cv2.VideoWriter_fourcc(*_tag), fps, (out_w, out_h) |
| ) |
| if candidate.isOpened(): |
| writer, used_fourcc = candidate, _tag |
| break |
| candidate.release() |
| if writer is None: |
| logger.warning("VideoWriter failed to open: %s", output_path) |
| return None |
|
|
| trail_history: dict[int, deque] = {j: deque(maxlen=TRAIL_LENGTH) for j in range(17)} |
| prev_kps: dict | None = None |
|
|
| for frame_idx, (frame, kps) in enumerate(zip(frames, scaled_keypoints)): |
| if scale != 1.0: |
| out_frame = cv2.resize(frame, (out_w, out_h), interpolation=cv2.INTER_AREA) |
| else: |
| out_frame = frame.copy() |
|
|
| if "trails" in layers: |
| for j, kp in kps.items(): |
| if kp.get("conf", 0.0) >= CONF_THRESHOLD: |
| trail_history[j].append((kp["x"], kp["y"])) |
| out_frame = self._draw_trails(out_frame, trail_history) |
|
|
| if "skeleton" in layers: |
| out_frame = self._draw_skeleton(out_frame, kps) |
|
|
| if "velocity_arrows" in layers: |
| out_frame = self._draw_velocity_arrows( |
| out_frame, kps, prev_kps, velocities, frame_idx |
| ) |
|
|
| writer.write(out_frame) |
| prev_kps = kps |
|
|
| writer.release() |
|
|
| |
| |
| if used_fourcc != "avc1": |
| import os |
| import shutil |
| import subprocess |
| if shutil.which("ffmpeg"): |
| raw = output_path + ".raw.mp4" |
| try: |
| os.replace(output_path, raw) |
| subprocess.run( |
| ["ffmpeg", "-y", "-i", raw, "-c:v", "libx264", |
| "-pix_fmt", "yuv420p", "-movflags", "+faststart", output_path], |
| check=True, capture_output=True, |
| ) |
| os.unlink(raw) |
| except Exception as e: |
| logger.warning("ffmpeg transcode failed (%s) β leaving mp4v", e) |
| if os.path.exists(raw): |
| os.replace(raw, output_path) |
| else: |
| logger.warning("wrote mp4v but no avc1/ffmpeg β overlay may not " |
| "play in-browser") |
|
|
| return output_path |
|
|
| except Exception as e: |
| logger.warning("render_video failed: %s", e) |
| return None |
|
|
| def render_frame( |
| self, |
| ingest, |
| pose2d, |
| frame_idx: int, |
| layers: set[str], |
| caption: str = "", |
| out_png: str | None = None, |
| ) -> str | None: |
| """Render a single annotated still (skeleton + optional trails + caption). |
| |
| frame_idx is typically the governing frame from BiomechFeatures.timing. |
| Returns the PNG path on success, None on any failure. Never raises. |
| """ |
| try: |
| if not (0 <= frame_idx < len(ingest.frames)) or frame_idx >= len(pose2d.keypoints): |
| return None |
|
|
| frame = ingest.frames[frame_idx].copy() |
| kps = pose2d.keypoints[frame_idx] |
|
|
| if "trails" in layers: |
| trail: dict[int, deque] = {j: deque(maxlen=TRAIL_LENGTH) for j in range(17)} |
| start = max(0, frame_idx - TRAIL_LENGTH) |
| for fi in range(start, frame_idx + 1): |
| for j, kp in pose2d.keypoints[fi].items(): |
| if kp.get("conf", 0.0) >= CONF_THRESHOLD: |
| trail[j].append((kp["x"], kp["y"])) |
| frame = self._draw_trails(frame, trail) |
|
|
| if "skeleton" in layers: |
| frame = self._draw_skeleton(frame, kps) |
|
|
| if caption: |
| cv2.rectangle(frame, (0, 0), (frame.shape[1], 28), (0, 0, 0), -1) |
| cv2.putText(frame, caption[:80], (8, 20), cv2.FONT_HERSHEY_SIMPLEX, |
| 0.55, (255, 255, 255), 1, cv2.LINE_AA) |
|
|
| if out_png is None: |
| out_png = tempfile.NamedTemporaryFile(suffix=".png", delete=False).name |
|
|
| ok = cv2.imwrite(out_png, frame) |
| return out_png if ok else None |
| except Exception as e: |
| logger.warning("render_frame failed: %s", e) |
| return None |
|
|
|
|
| |
|
|
| def build_velocity_summary( |
| keypoints_per_frame: list[dict], |
| velocities: dict[int, list[float]], |
| ) -> str: |
| """Return markdown table of per-joint avg/peak velocity. Empty string if no valid joints.""" |
| n_frames = len(keypoints_per_frame) |
| if n_frames == 0: |
| return "" |
|
|
| rows = [] |
| for j in range(17): |
| detected = sum( |
| 1 for kps in keypoints_per_frame |
| if kps.get(j, {}).get("conf", 0.0) >= CONF_THRESHOLD |
| ) |
| if detected < n_frames * 0.5: |
| continue |
|
|
| speeds = velocities.get(j, []) |
| if not speeds: |
| continue |
|
|
| avg_speed = sum(speeds) / len(speeds) |
| peak_speed = max(speeds) |
| rows.append((COCO_KEYPOINTS[j], avg_speed, peak_speed)) |
|
|
| if not rows: |
| return "" |
|
|
| rows.sort(key=lambda r: r[2], reverse=True) |
| lines = [ |
| "| Joint | Avg (px/s) | Peak (px/s) |", |
| "|---|---|---|", |
| ] |
| for name, avg, peak in rows: |
| lines.append(f"| {name} | {avg:.1f} | {peak:.1f} |") |
| return "\n".join(lines) |
|
|