Skip to content

API Reference

Complete API documentation for all public classes and functions.

ClimbingAnalysis

Complete analysis result with summary and detailed history.

Attributes:

Name Type Description
summary ClimbingSummary

Aggregated statistics

history Dict[str, list]

Frame-by-frame metric history

video_path str | None

Path to analyzed video

video_quality VideoQualityReport | None

Video quality report (if validation was run)

tracking_quality TrackingQualityReport | None

Pose tracking quality report (if validation was run)

Source code in src/climb_sensei/models.py
@dataclass(frozen=True)
class ClimbingAnalysis:
    """Complete analysis result with summary and detailed history.

    Attributes:
        summary: Aggregated statistics
        history: Frame-by-frame metric history
        video_path: Path to analyzed video
        video_quality: Video quality report (if validation was run)
        tracking_quality: Pose tracking quality report (if validation was run)
    """

    summary: ClimbingSummary
    history: Dict[str, list]
    video_path: str | None = None
    video_quality: "VideoQualityReport | None" = None
    tracking_quality: "TrackingQualityReport | None" = None

    def to_dict(self) -> Dict:
        """Convert to dictionary for serialization.

        Returns:
            Dictionary with summary, history, video_path, and quality reports
        """
        result = {
            "summary": self.summary.to_dict(),
            "history": self.history,
            "video_path": self.video_path,
        }

        # Add quality reports if present
        if self.video_quality:
            result["video_quality"] = {
                "is_valid": self.video_quality.is_valid,
                "resolution_quality": self.video_quality.resolution_quality,
                "fps_quality": self.video_quality.fps_quality,
                "duration_quality": self.video_quality.duration_quality,
                "issues": self.video_quality.issues,
                "warnings": self.video_quality.warnings,
            }

        if self.tracking_quality:
            result["tracking_quality"] = {
                "is_trackable": self.tracking_quality.is_trackable,
                "quality_level": self.tracking_quality.quality_level,
                "detection_rate": self.tracking_quality.detection_rate,
                "avg_confidence": self.tracking_quality.avg_landmark_confidence,
                "tracking_smoothness": self.tracking_quality.tracking_smoothness,
                "issues": self.tracking_quality.issues,
                "warnings": self.tracking_quality.warnings,
            }

        return result

    @classmethod
    def from_dict(cls, d: Dict) -> "ClimbingAnalysis":
        """Create ClimbingAnalysis from dictionary.

        Args:
            d: Dictionary with summary, history, and video_path

        Returns:
            ClimbingAnalysis instance
        """
        summary = ClimbingSummary.from_dict(d["summary"])
        return cls(
            summary=summary,
            history=d.get("history", {}),
            video_path=d.get("video_path"),
        )

from_dict(d) classmethod

Create ClimbingAnalysis from dictionary.

Parameters:

Name Type Description Default
d Dict

Dictionary with summary, history, and video_path

required

Returns:

Type Description
ClimbingAnalysis

ClimbingAnalysis instance

Source code in src/climb_sensei/models.py
@classmethod
def from_dict(cls, d: Dict) -> "ClimbingAnalysis":
    """Create ClimbingAnalysis from dictionary.

    Args:
        d: Dictionary with summary, history, and video_path

    Returns:
        ClimbingAnalysis instance
    """
    summary = ClimbingSummary.from_dict(d["summary"])
    return cls(
        summary=summary,
        history=d.get("history", {}),
        video_path=d.get("video_path"),
    )

to_dict()

Convert to dictionary for serialization.

Returns:

Type Description
Dict

Dictionary with summary, history, video_path, and quality reports

Source code in src/climb_sensei/models.py
def to_dict(self) -> Dict:
    """Convert to dictionary for serialization.

    Returns:
        Dictionary with summary, history, video_path, and quality reports
    """
    result = {
        "summary": self.summary.to_dict(),
        "history": self.history,
        "video_path": self.video_path,
    }

    # Add quality reports if present
    if self.video_quality:
        result["video_quality"] = {
            "is_valid": self.video_quality.is_valid,
            "resolution_quality": self.video_quality.resolution_quality,
            "fps_quality": self.video_quality.fps_quality,
            "duration_quality": self.video_quality.duration_quality,
            "issues": self.video_quality.issues,
            "warnings": self.video_quality.warnings,
        }

    if self.tracking_quality:
        result["tracking_quality"] = {
            "is_trackable": self.tracking_quality.is_trackable,
            "quality_level": self.tracking_quality.quality_level,
            "detection_rate": self.tracking_quality.detection_rate,
            "avg_confidence": self.tracking_quality.avg_landmark_confidence,
            "tracking_smoothness": self.tracking_quality.tracking_smoothness,
            "issues": self.tracking_quality.issues,
            "warnings": self.tracking_quality.warnings,
        }

    return result

options: show_source: true heading_level: 3


Video Quality

VideoQualityChecker

Validates video quality for climbing analysis.

Source code in src/climb_sensei/video_quality.py
class VideoQualityChecker:
    """Validates video quality for climbing analysis."""

    # Quality thresholds
    MIN_RESOLUTION = (640, 480)  # Minimum acceptable resolution
    RECOMMENDED_RESOLUTION = (1280, 720)  # HD recommended
    OPTIMAL_RESOLUTION = (1920, 1080)  # Full HD optimal

    MIN_FPS = 15  # Minimum for temporal analysis
    RECOMMENDED_FPS = 30  # Standard video
    OPTIMAL_FPS = 60  # High quality

    MIN_DURATION = 5.0  # Seconds - minimum meaningful climb
    MAX_DURATION = 600.0  # Seconds - 10 minutes max for processing
    RECOMMENDED_DURATION = (10.0, 180.0)  # 10s - 3min ideal

    MIN_BRIGHTNESS = 40  # 0-255 scale
    MAX_BRIGHTNESS = 215
    OPTIMAL_BRIGHTNESS = (80, 180)

    MAX_MOTION_BLUR_THRESHOLD = 100  # Laplacian variance threshold

    def __init__(self, deep_check: bool = False):
        """Initialize quality checker.

        Args:
            deep_check: If True, perform frame-by-frame analysis (slower)
        """
        self.deep_check = deep_check

    def check_video(self, video_path: str) -> VideoQualityReport:
        """Perform comprehensive video quality check.

        Args:
            video_path: Path to video file

        Returns:
            VideoQualityReport with detailed assessment

        Raises:
            FileNotFoundError: If video file doesn't exist
            ValueError: If file cannot be opened as video
        """
        path = Path(video_path)
        if not path.exists():
            raise FileNotFoundError(f"Video file not found: {video_path}")

        issues = []
        warnings = []
        recommendations = []

        # Get file size
        file_size_mb = path.stat().st_size / (1024 * 1024)

        # Open video
        cap = cv2.VideoCapture(str(path))
        if not cap.isOpened():
            raise ValueError(f"Cannot open video file: {video_path}")

        try:
            # Extract basic properties
            width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
            height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
            fps = cap.get(cv2.CAP_PROP_FPS)
            frame_count = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
            fourcc = int(cap.get(cv2.CAP_PROP_FOURCC))
            codec = self._fourcc_to_string(fourcc)

            # Calculate duration
            duration = frame_count / fps if fps > 0 else 0.0

            # Check format compatibility
            format_compatible = self._check_format_compatibility(codec)
            if not format_compatible:
                issues.append(f"Codec {codec} may not be fully supported")
                recommendations.append("Consider converting to H.264 (MP4)")

            # Check resolution
            resolution_quality = self._assess_resolution(width, height)
            if resolution_quality == QualityLevel.POOR:
                issues.append(f"Resolution {width}x{height} is below minimum")
            elif resolution_quality == QualityLevel.ACCEPTABLE:
                warnings.append(f"Resolution {width}x{height} is below recommended")
                recommendations.append(
                    f"Recommended minimum: {self.RECOMMENDED_RESOLUTION[0]}x{self.RECOMMENDED_RESOLUTION[1]}"
                )

            # Check FPS
            fps_quality = self._assess_fps(fps)
            if fps_quality == QualityLevel.POOR:
                issues.append(
                    f"Frame rate {fps} is below minimum for temporal analysis"
                )
            elif fps_quality == QualityLevel.ACCEPTABLE:
                warnings.append(f"Frame rate {fps} is below recommended")
                recommendations.append(
                    f"Recommended: {self.RECOMMENDED_FPS} fps or higher"
                )

            # Check duration
            duration_quality = self._assess_duration(duration)
            if duration_quality == QualityLevel.POOR:
                if duration < self.MIN_DURATION:
                    issues.append(f"Video duration {duration:.1f}s is too short")
                else:
                    warnings.append(
                        f"Video duration {duration:.1f}s is very long - may be slow to process"
                    )
            elif duration_quality == QualityLevel.ACCEPTABLE:
                warnings.append(
                    f"Video duration {duration:.1f}s is outside optimal range"
                )

            # Deep analysis (frame sampling)
            lighting_quality = None
            stability_quality = None

            if self.deep_check and frame_count > 0:
                lighting_quality, stability_quality = self._analyze_frames(
                    cap, frame_count, issues, warnings, recommendations
                )

            # Determine overall validity
            is_valid = (
                len(issues) == 0
                and format_compatible
                and resolution_quality != QualityLevel.POOR
                and fps_quality != QualityLevel.POOR
                and duration_quality != QualityLevel.POOR
            )

            # Add general recommendations
            if is_valid and len(warnings) == 0:
                recommendations.append(
                    "Video quality is excellent for climbing analysis"
                )
            elif is_valid:
                recommendations.append(
                    "Video is acceptable but improvements would enhance analysis quality"
                )

            return VideoQualityReport(
                is_valid=is_valid,
                file_path=str(path.absolute()),
                file_size_mb=round(file_size_mb, 2),
                width=width,
                height=height,
                fps=round(fps, 2),
                frame_count=frame_count,
                duration_seconds=round(duration, 2),
                codec=codec,
                format_compatible=format_compatible,
                resolution_quality=resolution_quality,
                fps_quality=fps_quality,
                duration_quality=duration_quality,
                lighting_quality=lighting_quality,
                stability_quality=stability_quality,
                issues=issues,
                warnings=warnings,
                recommendations=recommendations,
            )

        finally:
            cap.release()

    def _fourcc_to_string(self, fourcc: int) -> str:
        """Convert fourcc code to readable string."""
        return "".join([chr((fourcc >> 8 * i) & 0xFF) for i in range(4)])

    def _check_format_compatibility(self, codec: str) -> bool:
        """Check if video codec is compatible."""
        # Common compatible codecs
        compatible_codecs = [
            "avc1",  # H.264
            "h264",
            "H264",
            "x264",
            "mp4v",  # MPEG-4
            "MP4V",
            "XVID",
            "xvid",
            "DIVX",
            "divx",
        ]
        return any(c in codec for c in compatible_codecs) or codec.strip() == ""

    def _assess_resolution(self, width: int, height: int) -> str:
        """Assess resolution quality."""
        if width >= self.OPTIMAL_RESOLUTION[0] and height >= self.OPTIMAL_RESOLUTION[1]:
            return QualityLevel.EXCELLENT
        elif (
            width >= self.RECOMMENDED_RESOLUTION[0]
            and height >= self.RECOMMENDED_RESOLUTION[1]
        ):
            return QualityLevel.GOOD
        elif width >= self.MIN_RESOLUTION[0] and height >= self.MIN_RESOLUTION[1]:
            return QualityLevel.ACCEPTABLE
        else:
            return QualityLevel.POOR

    def _assess_fps(self, fps: float) -> str:
        """Assess frame rate quality."""
        if fps >= self.OPTIMAL_FPS:
            return QualityLevel.EXCELLENT
        elif fps >= self.RECOMMENDED_FPS:
            return QualityLevel.GOOD
        elif fps >= self.MIN_FPS:
            return QualityLevel.ACCEPTABLE
        else:
            return QualityLevel.POOR

    def _assess_duration(self, duration: float) -> str:
        """Assess video duration."""
        if duration < self.MIN_DURATION:
            return QualityLevel.POOR
        elif duration > self.MAX_DURATION:
            return QualityLevel.POOR
        elif self.RECOMMENDED_DURATION[0] <= duration <= self.RECOMMENDED_DURATION[1]:
            return QualityLevel.EXCELLENT
        else:
            return QualityLevel.ACCEPTABLE

    def _analyze_frames(
        self,
        cap: cv2.VideoCapture,
        frame_count: int,
        issues: List[str],
        warnings: List[str],
        recommendations: List[str],
    ) -> Tuple[str, str]:
        """Analyze frame quality through sampling.

        Returns:
            Tuple of (lighting_quality, stability_quality)
        """
        # Sample frames (check every 30th frame or 10 samples, whichever is more)
        sample_interval = max(1, frame_count // 10)
        sample_interval = min(sample_interval, 30)

        brightness_values = []
        blur_values = []

        frame_idx = 0
        while frame_idx < frame_count:
            cap.set(cv2.CAP_PROP_POS_FRAMES, frame_idx)
            ret, frame = cap.read()
            if not ret:
                break

            # Convert to grayscale for analysis
            gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)

            # Check brightness
            brightness = np.mean(gray)
            brightness_values.append(brightness)

            # Check blur (Laplacian variance)
            laplacian_var = cv2.Laplacian(gray, cv2.CV_64F).var()
            blur_values.append(laplacian_var)

            frame_idx += sample_interval

        # Assess lighting
        avg_brightness = np.mean(brightness_values)
        lighting_quality = self._assess_lighting(
            avg_brightness, issues, warnings, recommendations
        )

        # Assess stability/blur
        avg_blur = np.mean(blur_values)
        stability_quality = self._assess_stability(
            avg_blur, issues, warnings, recommendations
        )

        return lighting_quality, stability_quality

    def _assess_lighting(
        self,
        avg_brightness: float,
        issues: List[str],
        warnings: List[str],
        recommendations: List[str],
    ) -> str:
        """Assess lighting conditions."""
        if avg_brightness < self.MIN_BRIGHTNESS:
            issues.append(f"Video is too dark (brightness: {avg_brightness:.1f}/255)")
            recommendations.append("Improve lighting or adjust camera exposure")
            return QualityLevel.POOR
        elif avg_brightness > self.MAX_BRIGHTNESS:
            warnings.append(
                f"Video is overexposed (brightness: {avg_brightness:.1f}/255)"
            )
            recommendations.append("Reduce exposure or lighting intensity")
            return QualityLevel.ACCEPTABLE
        elif self.OPTIMAL_BRIGHTNESS[0] <= avg_brightness <= self.OPTIMAL_BRIGHTNESS[1]:
            return QualityLevel.EXCELLENT
        else:
            return QualityLevel.GOOD

    def _assess_stability(
        self,
        avg_blur: float,
        issues: List[str],
        warnings: List[str],
        recommendations: List[str],
    ) -> str:
        """Assess camera stability and motion blur."""
        if avg_blur < self.MAX_MOTION_BLUR_THRESHOLD:
            warnings.append(
                f"Possible motion blur detected (sharpness: {avg_blur:.1f})"
            )
            recommendations.append("Use a tripod or stabilization for better results")
            return QualityLevel.ACCEPTABLE
        else:
            return QualityLevel.EXCELLENT

__init__(deep_check=False)

Initialize quality checker.

Parameters:

Name Type Description Default
deep_check bool

If True, perform frame-by-frame analysis (slower)

False
Source code in src/climb_sensei/video_quality.py
def __init__(self, deep_check: bool = False):
    """Initialize quality checker.

    Args:
        deep_check: If True, perform frame-by-frame analysis (slower)
    """
    self.deep_check = deep_check

check_video(video_path)

Perform comprehensive video quality check.

Parameters:

Name Type Description Default
video_path str

Path to video file

required

Returns:

Type Description
VideoQualityReport

VideoQualityReport with detailed assessment

Raises:

Type Description
FileNotFoundError

If video file doesn't exist

ValueError

If file cannot be opened as video

Source code in src/climb_sensei/video_quality.py
def check_video(self, video_path: str) -> VideoQualityReport:
    """Perform comprehensive video quality check.

    Args:
        video_path: Path to video file

    Returns:
        VideoQualityReport with detailed assessment

    Raises:
        FileNotFoundError: If video file doesn't exist
        ValueError: If file cannot be opened as video
    """
    path = Path(video_path)
    if not path.exists():
        raise FileNotFoundError(f"Video file not found: {video_path}")

    issues = []
    warnings = []
    recommendations = []

    # Get file size
    file_size_mb = path.stat().st_size / (1024 * 1024)

    # Open video
    cap = cv2.VideoCapture(str(path))
    if not cap.isOpened():
        raise ValueError(f"Cannot open video file: {video_path}")

    try:
        # Extract basic properties
        width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
        height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
        fps = cap.get(cv2.CAP_PROP_FPS)
        frame_count = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
        fourcc = int(cap.get(cv2.CAP_PROP_FOURCC))
        codec = self._fourcc_to_string(fourcc)

        # Calculate duration
        duration = frame_count / fps if fps > 0 else 0.0

        # Check format compatibility
        format_compatible = self._check_format_compatibility(codec)
        if not format_compatible:
            issues.append(f"Codec {codec} may not be fully supported")
            recommendations.append("Consider converting to H.264 (MP4)")

        # Check resolution
        resolution_quality = self._assess_resolution(width, height)
        if resolution_quality == QualityLevel.POOR:
            issues.append(f"Resolution {width}x{height} is below minimum")
        elif resolution_quality == QualityLevel.ACCEPTABLE:
            warnings.append(f"Resolution {width}x{height} is below recommended")
            recommendations.append(
                f"Recommended minimum: {self.RECOMMENDED_RESOLUTION[0]}x{self.RECOMMENDED_RESOLUTION[1]}"
            )

        # Check FPS
        fps_quality = self._assess_fps(fps)
        if fps_quality == QualityLevel.POOR:
            issues.append(
                f"Frame rate {fps} is below minimum for temporal analysis"
            )
        elif fps_quality == QualityLevel.ACCEPTABLE:
            warnings.append(f"Frame rate {fps} is below recommended")
            recommendations.append(
                f"Recommended: {self.RECOMMENDED_FPS} fps or higher"
            )

        # Check duration
        duration_quality = self._assess_duration(duration)
        if duration_quality == QualityLevel.POOR:
            if duration < self.MIN_DURATION:
                issues.append(f"Video duration {duration:.1f}s is too short")
            else:
                warnings.append(
                    f"Video duration {duration:.1f}s is very long - may be slow to process"
                )
        elif duration_quality == QualityLevel.ACCEPTABLE:
            warnings.append(
                f"Video duration {duration:.1f}s is outside optimal range"
            )

        # Deep analysis (frame sampling)
        lighting_quality = None
        stability_quality = None

        if self.deep_check and frame_count > 0:
            lighting_quality, stability_quality = self._analyze_frames(
                cap, frame_count, issues, warnings, recommendations
            )

        # Determine overall validity
        is_valid = (
            len(issues) == 0
            and format_compatible
            and resolution_quality != QualityLevel.POOR
            and fps_quality != QualityLevel.POOR
            and duration_quality != QualityLevel.POOR
        )

        # Add general recommendations
        if is_valid and len(warnings) == 0:
            recommendations.append(
                "Video quality is excellent for climbing analysis"
            )
        elif is_valid:
            recommendations.append(
                "Video is acceptable but improvements would enhance analysis quality"
            )

        return VideoQualityReport(
            is_valid=is_valid,
            file_path=str(path.absolute()),
            file_size_mb=round(file_size_mb, 2),
            width=width,
            height=height,
            fps=round(fps, 2),
            frame_count=frame_count,
            duration_seconds=round(duration, 2),
            codec=codec,
            format_compatible=format_compatible,
            resolution_quality=resolution_quality,
            fps_quality=fps_quality,
            duration_quality=duration_quality,
            lighting_quality=lighting_quality,
            stability_quality=stability_quality,
            issues=issues,
            warnings=warnings,
            recommendations=recommendations,
        )

    finally:
        cap.release()

options: show_source: true heading_level: 3

VideoQualityReport

Comprehensive video quality assessment report.

Attributes:

Name Type Description
is_valid bool

Overall validity for climbing analysis

file_path str

Path to video file

file_size_mb float

File size in megabytes

width int

Video width in pixels

height int

Video height in pixels

fps float

Frames per second

frame_count int

Total number of frames

duration_seconds float

Video duration in seconds

codec str

Video codec (fourcc code)

format_compatible bool

Whether format is supported

resolution_quality str

Resolution quality assessment

fps_quality str

Frame rate quality assessment

duration_quality str

Duration quality assessment

lighting_quality Optional[str]

Lighting conditions assessment

stability_quality Optional[str]

Camera stability assessment

issues List[str]

List of detected issues

warnings List[str]

List of warnings

recommendations List[str]

List of recommendations

Source code in src/climb_sensei/video_quality.py
@dataclass
class VideoQualityReport:
    """Comprehensive video quality assessment report.

    Attributes:
        is_valid: Overall validity for climbing analysis
        file_path: Path to video file
        file_size_mb: File size in megabytes
        width: Video width in pixels
        height: Video height in pixels
        fps: Frames per second
        frame_count: Total number of frames
        duration_seconds: Video duration in seconds
        codec: Video codec (fourcc code)
        format_compatible: Whether format is supported
        resolution_quality: Resolution quality assessment
        fps_quality: Frame rate quality assessment
        duration_quality: Duration quality assessment
        lighting_quality: Lighting conditions assessment
        stability_quality: Camera stability assessment
        issues: List of detected issues
        warnings: List of warnings
        recommendations: List of recommendations
    """

    is_valid: bool
    file_path: str
    file_size_mb: float
    width: int
    height: int
    fps: float
    frame_count: int
    duration_seconds: float
    codec: str
    format_compatible: bool
    resolution_quality: str  # "excellent", "good", "acceptable", "poor"
    fps_quality: str
    duration_quality: str
    lighting_quality: Optional[str]  # None if not analyzed
    stability_quality: Optional[str]  # None if not analyzed
    issues: List[str]
    warnings: List[str]
    recommendations: List[str]

options: show_source: true heading_level: 3

check_video_quality

Convenience function to check video quality.

Parameters:

Name Type Description Default
video_path str

Path to video file

required
deep_check bool

Whether to perform deep frame analysis

False

Returns:

Type Description
VideoQualityReport

VideoQualityReport

Source code in src/climb_sensei/video_quality.py
def check_video_quality(
    video_path: str, deep_check: bool = False
) -> VideoQualityReport:
    """Convenience function to check video quality.

    Args:
        video_path: Path to video file
        deep_check: Whether to perform deep frame analysis

    Returns:
        VideoQualityReport
    """
    checker = VideoQualityChecker(deep_check=deep_check)
    return checker.check_video(video_path)

options: show_source: true heading_level: 3


Tracking Quality

TrackingQualityAnalyzer

Analyze pose tracking quality in climbing videos.

This analyzer runs pose detection on a video and evaluates: - Detection rate: How often poses are detected - Landmark confidence: Average confidence scores - Visibility: Percentage of landmarks visible - Smoothness: Consistency of tracking (low jitter) - Tracking loss: Number of times tracking is lost

Parameters:

Name Type Description Default
min_detection_rate float

Minimum acceptable detection rate (0-100)

70.0
min_avg_confidence float

Minimum acceptable average confidence (0-1)

0.5
min_visibility float

Minimum acceptable visibility percentage (0-100)

60.0
min_smoothness float

Minimum acceptable smoothness score (0-1)

0.6
max_tracking_losses int

Maximum acceptable tracking loss events

5
sample_rate int

Analyze every Nth frame (1 = every frame)

1
pose_detection_confidence float

Confidence threshold for pose detection

0.5
pose_tracking_confidence float

Confidence threshold for pose tracking

0.5
Source code in src/climb_sensei/tracking_quality.py
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
class TrackingQualityAnalyzer:
    """Analyze pose tracking quality in climbing videos.

    This analyzer runs pose detection on a video and evaluates:
    - Detection rate: How often poses are detected
    - Landmark confidence: Average confidence scores
    - Visibility: Percentage of landmarks visible
    - Smoothness: Consistency of tracking (low jitter)
    - Tracking loss: Number of times tracking is lost

    Args:
        min_detection_rate: Minimum acceptable detection rate (0-100)
        min_avg_confidence: Minimum acceptable average confidence (0-1)
        min_visibility: Minimum acceptable visibility percentage (0-100)
        min_smoothness: Minimum acceptable smoothness score (0-1)
        max_tracking_losses: Maximum acceptable tracking loss events
        sample_rate: Analyze every Nth frame (1 = every frame)
        pose_detection_confidence: Confidence threshold for pose detection
        pose_tracking_confidence: Confidence threshold for pose tracking
    """

    def __init__(
        self,
        min_detection_rate: float = 70.0,
        min_avg_confidence: float = 0.5,
        min_visibility: float = 60.0,
        min_smoothness: float = 0.6,
        max_tracking_losses: int = 5,
        sample_rate: int = 1,
        pose_detection_confidence: float = 0.5,
        pose_tracking_confidence: float = 0.5,
    ):
        self.min_detection_rate = min_detection_rate
        self.min_avg_confidence = min_avg_confidence
        self.min_visibility = min_visibility
        self.min_smoothness = min_smoothness
        self.max_tracking_losses = max_tracking_losses
        self.sample_rate = sample_rate
        self.pose_detection_confidence = pose_detection_confidence
        self.pose_tracking_confidence = pose_tracking_confidence

    def analyze_video(self, video_path: str) -> TrackingQualityReport:
        """Analyze tracking quality for a video.

        Args:
            video_path: Path to video file

        Returns:
            TrackingQualityReport with analysis results

        Raises:
            FileNotFoundError: If video file doesn't exist
            ValueError: If video cannot be opened
        """
        video_path = str(Path(video_path).resolve())

        if not Path(video_path).exists():
            raise FileNotFoundError(f"Video file not found: {video_path}")

        # Analyze frames
        results = self._analyze_frames_from_video(video_path)

        # Generate report
        report = self._generate_report(video_path, results)

        return report

    def analyze_from_landmarks(
        self,
        landmarks_sequence: List[Optional[List[Tuple[float, float]]]],
        file_path: str = "landmarks_sequence",
    ) -> TrackingQualityReport:
        """Analyze tracking quality from pre-extracted landmark sequence.

        This is more efficient than analyze_video() when landmarks are already
        available (e.g., during analyze_climb processing).

        Args:
            landmarks_sequence: List of landmark lists, one per frame.
                Each frame is either None (no detection) or a list of (x, y) tuples.
                Use None for frames where pose was not detected.
            file_path: Optional identifier for the source (for reporting)

        Returns:
            TrackingQualityReport with analysis results

        Example:
            >>> landmarks_seq = [
            ...     [(0.5, 0.5), (0.6, 0.4), ...],  # Frame 0
            ...     [(0.5, 0.5), (0.6, 0.4), ...],  # Frame 1
            ...     None,  # Frame 2 - no detection
            ...     [(0.5, 0.5), (0.6, 0.4), ...],  # Frame 3
            ... ]
            >>> analyzer = TrackingQualityAnalyzer()
            >>> report = analyzer.analyze_from_landmarks(landmarks_seq)
        """
        results = self._analyze_landmarks_sequence(landmarks_sequence)
        report = self._generate_report(file_path, results)
        return report

    def _analyze_landmarks_sequence(
        self, landmarks_sequence: List[Optional[List[Tuple[float, float]]]]
    ) -> Dict[str, any]:
        """Analyze pre-extracted landmark sequence."""
        frame_count = 0
        frames_with_pose = 0
        frame_confidences = []
        frame_visibility = []
        landmark_positions = []
        tracking_losses = 0
        previous_had_pose = False

        for frame_idx, landmarks in enumerate(landmarks_sequence):
            # Apply sampling
            if frame_idx % self.sample_rate != 0:
                continue

            frame_count += 1

            if landmarks is not None and len(landmarks) > 0:
                frames_with_pose += 1

                # For pre-extracted landmarks, we don't have visibility scores
                # Assume all landmarks are visible with high confidence
                # In a real scenario, this data would come from the pose engine
                confidence = 0.8  # Assume good confidence if detected
                visibility_pct = 100.0  # All landmarks visible

                frame_confidences.append(confidence)
                frame_visibility.append(visibility_pct)

                # Convert 2D landmarks to 3D for consistency (z=0)
                positions_3d = [(x, y, 0.0) for x, y in landmarks]
                landmark_positions.append(positions_3d)

                # Track if we regained tracking
                if not previous_had_pose:
                    tracking_losses += 1
                previous_had_pose = True
            else:
                frame_confidences.append(0.0)
                frame_visibility.append(0.0)
                previous_had_pose = False

        return {
            "total_frames": frame_count,
            "frames_with_pose": frames_with_pose,
            "frame_confidences": frame_confidences,
            "frame_visibility": frame_visibility,
            "landmark_positions": landmark_positions,
            "tracking_losses": max(0, tracking_losses - 1),
        }

    def _analyze_frames_from_video(self, video_path: str) -> Dict[str, any]:
        """Analyze all frames for tracking quality."""
        frame_count = 0
        frames_with_pose = 0
        frame_confidences = []
        frame_visibility = []
        landmark_positions = []  # For smoothness calculation
        tracking_losses = 0
        previous_had_pose = False

        with PoseEngine(
            min_detection_confidence=self.pose_detection_confidence,
            min_tracking_confidence=self.pose_tracking_confidence,
        ) as engine:
            with VideoReader(video_path) as reader:
                while True:
                    success, frame = reader.read()
                    if not success:
                        break

                    # Sample frames
                    if frame_count % self.sample_rate != 0:
                        frame_count += 1
                        continue

                    frame_count += 1

                    # Process frame
                    results = engine.process(frame)

                    if results and results.pose_landmarks:
                        landmarks = engine.extract_landmarks(results)

                        if landmarks and len(landmarks) > 0:
                            frames_with_pose += 1

                            # Calculate confidence (from landmark visibility)
                            confidences = []
                            visible_count = 0
                            positions = []

                            for lm in results.pose_landmarks.landmark:
                                confidences.append(lm.visibility)
                                if lm.visibility > 0.5:
                                    visible_count += 1
                                positions.append((lm.x, lm.y, lm.z))

                            avg_confidence = np.mean(confidences)
                            visibility_pct = (
                                visible_count / len(results.pose_landmarks.landmark)
                            ) * 100

                            frame_confidences.append(avg_confidence)
                            frame_visibility.append(visibility_pct)
                            landmark_positions.append(positions)

                            # Track if we regained tracking
                            if not previous_had_pose:
                                tracking_losses += 1
                            previous_had_pose = True
                        else:
                            frame_confidences.append(0.0)
                            frame_visibility.append(0.0)
                            previous_had_pose = False
                    else:
                        frame_confidences.append(0.0)
                        frame_visibility.append(0.0)
                        previous_had_pose = False

        return {
            "total_frames": frame_count,
            "frames_with_pose": frames_with_pose,
            "frame_confidences": frame_confidences,
            "frame_visibility": frame_visibility,
            "landmark_positions": landmark_positions,
            "tracking_losses": max(
                0, tracking_losses - 1
            ),  # First detection isn't a "loss"
        }

    def _calculate_smoothness(
        self, landmark_positions: List[List[Tuple[float, float, float]]]
    ) -> float:
        """Calculate tracking smoothness based on landmark jitter.

        Higher values indicate smoother tracking (less jitter).

        Args:
            landmark_positions: List of landmark positions per frame

        Returns:
            Smoothness score between 0 and 1
        """
        if len(landmark_positions) < 3:
            return 0.0

        # Calculate average movement per landmark across frames
        total_jitter = 0.0
        valid_comparisons = 0

        for i in range(1, len(landmark_positions)):
            prev_frame = landmark_positions[i - 1]
            curr_frame = landmark_positions[i]

            if len(prev_frame) == len(curr_frame):
                for prev_lm, curr_lm in zip(prev_frame, curr_frame):
                    # Calculate 3D distance
                    dx = curr_lm[0] - prev_lm[0]
                    dy = curr_lm[1] - prev_lm[1]
                    dz = curr_lm[2] - prev_lm[2]
                    dist = np.sqrt(dx**2 + dy**2 + dz**2)
                    total_jitter += dist
                    valid_comparisons += 1

        if valid_comparisons == 0:
            return 0.0

        avg_jitter = total_jitter / valid_comparisons

        # Convert jitter to smoothness score (0-1)
        # Typical jitter ranges from 0.001 (very smooth) to 0.1+ (very jittery)
        # Use exponential decay to map to 0-1 scale
        smoothness = np.exp(-avg_jitter * 20)

        return float(np.clip(smoothness, 0.0, 1.0))

    def _generate_report(
        self, video_path: str, results: Dict[str, any]
    ) -> TrackingQualityReport:
        """Generate tracking quality report from analysis results."""
        total_frames = results["total_frames"]
        frames_with_pose = results["frames_with_pose"]
        frame_confidences = results["frame_confidences"]
        frame_visibility = results["frame_visibility"]
        landmark_positions = results["landmark_positions"]
        tracking_losses = results["tracking_losses"]

        # Calculate metrics
        detection_rate = (
            (frames_with_pose / total_frames * 100) if total_frames > 0 else 0.0
        )

        # Only consider frames with detected poses for averages
        valid_confidences = [c for c in frame_confidences if c > 0]
        avg_confidence = np.mean(valid_confidences) if valid_confidences else 0.0
        min_confidence = np.min(valid_confidences) if valid_confidences else 0.0

        valid_visibility = [v for v in frame_visibility if v > 0]
        avg_visibility = np.mean(valid_visibility) if valid_visibility else 0.0

        smoothness = self._calculate_smoothness(landmark_positions)

        # Determine quality issues
        issues = []
        warnings = []

        if detection_rate < self.min_detection_rate:
            issues.append(
                f"Low detection rate: {detection_rate:.1f}% "
                f"(minimum: {self.min_detection_rate:.1f}%)"
            )

        if avg_confidence < self.min_avg_confidence:
            issues.append(
                f"Low landmark confidence: {avg_confidence:.2f} "
                f"(minimum: {self.min_avg_confidence:.2f})"
            )

        if avg_visibility < self.min_visibility:
            issues.append(
                f"Low landmark visibility: {avg_visibility:.1f}% "
                f"(minimum: {self.min_visibility:.1f}%)"
            )

        if smoothness < self.min_smoothness:
            warnings.append(
                f"Tracking is jittery: smoothness {smoothness:.2f} "
                f"(recommended: {self.min_smoothness:.2f})"
            )

        if tracking_losses > self.max_tracking_losses:
            warnings.append(
                f"Frequent tracking loss: {tracking_losses} events "
                f"(maximum recommended: {self.max_tracking_losses})"
            )

        # Determine overall quality level
        is_trackable = len(issues) == 0
        quality_level = self._determine_quality_level(
            detection_rate, avg_confidence, avg_visibility, smoothness
        )

        return TrackingQualityReport(
            file_path=video_path,
            total_frames=total_frames,
            frames_with_pose=frames_with_pose,
            detection_rate=round(detection_rate, 2),
            avg_landmark_confidence=round(avg_confidence, 3),
            min_landmark_confidence=round(min_confidence, 3),
            avg_visibility_score=round(avg_visibility, 2),
            tracking_smoothness=round(smoothness, 3),
            tracking_loss_events=tracking_losses,
            is_trackable=is_trackable,
            issues=issues,
            warnings=warnings,
            quality_level=quality_level,
            frame_confidences=[round(c, 3) for c in frame_confidences],
            frame_visibility=[round(v, 2) for v in frame_visibility],
        )

    def _determine_quality_level(
        self,
        detection_rate: float,
        avg_confidence: float,
        avg_visibility: float,
        smoothness: float,
    ) -> str:
        """Determine overall tracking quality level."""
        # Excellent: All metrics exceed thresholds significantly
        if (
            detection_rate >= 95
            and avg_confidence >= 0.8
            and avg_visibility >= 85
            and smoothness >= 0.8
        ):
            return QualityLevel.EXCELLENT

        # Good: All metrics meet or exceed thresholds
        if (
            detection_rate >= self.min_detection_rate
            and avg_confidence >= self.min_avg_confidence
            and avg_visibility >= self.min_visibility
            and smoothness >= self.min_smoothness
        ):
            return QualityLevel.GOOD

        # Acceptable: Meets minimum thresholds even if some warnings
        if (
            detection_rate >= self.min_detection_rate
            and avg_confidence >= self.min_avg_confidence
            and avg_visibility >= self.min_visibility
        ):
            return QualityLevel.ACCEPTABLE

        # Poor: Does not meet minimum requirements
        return QualityLevel.POOR

analyze_from_landmarks(landmarks_sequence, file_path='landmarks_sequence')

Analyze tracking quality from pre-extracted landmark sequence.

This is more efficient than analyze_video() when landmarks are already available (e.g., during analyze_climb processing).

Parameters:

Name Type Description Default
landmarks_sequence List[Optional[List[Tuple[float, float]]]]

List of landmark lists, one per frame. Each frame is either None (no detection) or a list of (x, y) tuples. Use None for frames where pose was not detected.

required
file_path str

Optional identifier for the source (for reporting)

'landmarks_sequence'

Returns:

Type Description
TrackingQualityReport

TrackingQualityReport with analysis results

Example

landmarks_seq = [ ... [(0.5, 0.5), (0.6, 0.4), ...], # Frame 0 ... [(0.5, 0.5), (0.6, 0.4), ...], # Frame 1 ... None, # Frame 2 - no detection ... [(0.5, 0.5), (0.6, 0.4), ...], # Frame 3 ... ] analyzer = TrackingQualityAnalyzer() report = analyzer.analyze_from_landmarks(landmarks_seq)

Source code in src/climb_sensei/tracking_quality.py
def analyze_from_landmarks(
    self,
    landmarks_sequence: List[Optional[List[Tuple[float, float]]]],
    file_path: str = "landmarks_sequence",
) -> TrackingQualityReport:
    """Analyze tracking quality from pre-extracted landmark sequence.

    This is more efficient than analyze_video() when landmarks are already
    available (e.g., during analyze_climb processing).

    Args:
        landmarks_sequence: List of landmark lists, one per frame.
            Each frame is either None (no detection) or a list of (x, y) tuples.
            Use None for frames where pose was not detected.
        file_path: Optional identifier for the source (for reporting)

    Returns:
        TrackingQualityReport with analysis results

    Example:
        >>> landmarks_seq = [
        ...     [(0.5, 0.5), (0.6, 0.4), ...],  # Frame 0
        ...     [(0.5, 0.5), (0.6, 0.4), ...],  # Frame 1
        ...     None,  # Frame 2 - no detection
        ...     [(0.5, 0.5), (0.6, 0.4), ...],  # Frame 3
        ... ]
        >>> analyzer = TrackingQualityAnalyzer()
        >>> report = analyzer.analyze_from_landmarks(landmarks_seq)
    """
    results = self._analyze_landmarks_sequence(landmarks_sequence)
    report = self._generate_report(file_path, results)
    return report

analyze_video(video_path)

Analyze tracking quality for a video.

Parameters:

Name Type Description Default
video_path str

Path to video file

required

Returns:

Type Description
TrackingQualityReport

TrackingQualityReport with analysis results

Raises:

Type Description
FileNotFoundError

If video file doesn't exist

ValueError

If video cannot be opened

Source code in src/climb_sensei/tracking_quality.py
def analyze_video(self, video_path: str) -> TrackingQualityReport:
    """Analyze tracking quality for a video.

    Args:
        video_path: Path to video file

    Returns:
        TrackingQualityReport with analysis results

    Raises:
        FileNotFoundError: If video file doesn't exist
        ValueError: If video cannot be opened
    """
    video_path = str(Path(video_path).resolve())

    if not Path(video_path).exists():
        raise FileNotFoundError(f"Video file not found: {video_path}")

    # Analyze frames
    results = self._analyze_frames_from_video(video_path)

    # Generate report
    report = self._generate_report(video_path, results)

    return report

options: show_source: true heading_level: 3

TrackingQualityReport

Report of pose tracking quality analysis.

Attributes:

Name Type Description
file_path str

Path to analyzed video file

total_frames int

Total number of frames analyzed

frames_with_pose int

Number of frames where pose was detected

detection_rate float

Percentage of frames with pose detected (0-100)

avg_landmark_confidence float

Average confidence across all landmarks (0-1)

min_landmark_confidence float

Minimum average confidence in any frame (0-1)

avg_visibility_score float

Average percentage of visible landmarks (0-100)

tracking_smoothness float

Smoothness score based on landmark jitter (0-1, higher=smoother)

tracking_loss_events int

Number of times tracking was lost then regained

is_trackable bool

Whether video has sufficient tracking quality

issues List[str]

List of critical tracking issues

warnings List[str]

List of tracking quality warnings

quality_level str

Overall tracking quality ('poor', 'acceptable', 'good', 'excellent')

frame_confidences List[float]

Per-frame average confidence scores

frame_visibility List[float]

Per-frame visibility percentages

Source code in src/climb_sensei/tracking_quality.py
@dataclass
class TrackingQualityReport:
    """Report of pose tracking quality analysis.

    Attributes:
        file_path: Path to analyzed video file
        total_frames: Total number of frames analyzed
        frames_with_pose: Number of frames where pose was detected
        detection_rate: Percentage of frames with pose detected (0-100)
        avg_landmark_confidence: Average confidence across all landmarks (0-1)
        min_landmark_confidence: Minimum average confidence in any frame (0-1)
        avg_visibility_score: Average percentage of visible landmarks (0-100)
        tracking_smoothness: Smoothness score based on landmark jitter (0-1, higher=smoother)
        tracking_loss_events: Number of times tracking was lost then regained
        is_trackable: Whether video has sufficient tracking quality
        issues: List of critical tracking issues
        warnings: List of tracking quality warnings
        quality_level: Overall tracking quality ('poor', 'acceptable', 'good', 'excellent')
        frame_confidences: Per-frame average confidence scores
        frame_visibility: Per-frame visibility percentages
    """

    file_path: str
    total_frames: int
    frames_with_pose: int
    detection_rate: float
    avg_landmark_confidence: float
    min_landmark_confidence: float
    avg_visibility_score: float
    tracking_smoothness: float
    tracking_loss_events: int
    is_trackable: bool
    issues: List[str] = field(default_factory=list)
    warnings: List[str] = field(default_factory=list)
    quality_level: str = "unknown"
    frame_confidences: List[float] = field(default_factory=list)
    frame_visibility: List[float] = field(default_factory=list)

options: show_source: true heading_level: 3

analyze_tracking_quality

Convenience function to analyze tracking quality from video.

Parameters:

Name Type Description Default
video_path str

Path to video file

required
sample_rate int

Analyze every Nth frame (1 = every frame)

1
min_detection_rate float

Minimum acceptable detection rate percentage

70.0

Returns:

Type Description
TrackingQualityReport

TrackingQualityReport with analysis results

Example

report = analyze_tracking_quality('climbing.mp4') if report.is_trackable: ... print(f"Detection rate: {report.detection_rate}%") ... else: ... print("Tracking issues:", report.issues)

Source code in src/climb_sensei/tracking_quality.py
def analyze_tracking_quality(
    video_path: str,
    sample_rate: int = 1,
    min_detection_rate: float = 70.0,
) -> TrackingQualityReport:
    """Convenience function to analyze tracking quality from video.

    Args:
        video_path: Path to video file
        sample_rate: Analyze every Nth frame (1 = every frame)
        min_detection_rate: Minimum acceptable detection rate percentage

    Returns:
        TrackingQualityReport with analysis results

    Example:
        >>> report = analyze_tracking_quality('climbing.mp4')
        >>> if report.is_trackable:
        ...     print(f"Detection rate: {report.detection_rate}%")
        ... else:
        ...     print("Tracking issues:", report.issues)
    """
    analyzer = TrackingQualityAnalyzer(
        min_detection_rate=min_detection_rate,
        sample_rate=sample_rate,
    )
    return analyzer.analyze_video(video_path)

options: show_source: true heading_level: 3

analyze_tracking_from_landmarks

Convenience function to analyze tracking quality from landmarks.

More efficient than analyze_tracking_quality() when landmarks are already available from pose detection.

Parameters:

Name Type Description Default
landmarks_sequence List[Optional[List[Tuple[float, float]]]]

List of landmark lists, one per frame. Each frame is either None (no detection) or list of (x, y) tuples.

required
sample_rate int

Analyze every Nth frame (1 = every frame)

1
min_detection_rate float

Minimum acceptable detection rate percentage

70.0
file_path str

Optional identifier for the source

'landmarks_sequence'

Returns:

Type Description
TrackingQualityReport

TrackingQualityReport with analysis results

Example

During analysis

landmarks_history = [] for frame in video: ... landmarks = detect_pose(frame) ... landmarks_history.append(landmarks)

Analyze tracking quality

report = analyze_tracking_from_landmarks(landmarks_history) print(f"Smoothness: {report.tracking_smoothness:.3f}")

Source code in src/climb_sensei/tracking_quality.py
def analyze_tracking_from_landmarks(
    landmarks_sequence: List[Optional[List[Tuple[float, float]]]],
    sample_rate: int = 1,
    min_detection_rate: float = 70.0,
    file_path: str = "landmarks_sequence",
) -> TrackingQualityReport:
    """Convenience function to analyze tracking quality from landmarks.

    More efficient than analyze_tracking_quality() when landmarks are already
    available from pose detection.

    Args:
        landmarks_sequence: List of landmark lists, one per frame.
            Each frame is either None (no detection) or list of (x, y) tuples.
        sample_rate: Analyze every Nth frame (1 = every frame)
        min_detection_rate: Minimum acceptable detection rate percentage
        file_path: Optional identifier for the source

    Returns:
        TrackingQualityReport with analysis results

    Example:
        >>> # During analysis
        >>> landmarks_history = []
        >>> for frame in video:
        ...     landmarks = detect_pose(frame)
        ...     landmarks_history.append(landmarks)
        >>>
        >>> # Analyze tracking quality
        >>> report = analyze_tracking_from_landmarks(landmarks_history)
        >>> print(f"Smoothness: {report.tracking_smoothness:.3f}")
    """
    analyzer = TrackingQualityAnalyzer(
        min_detection_rate=min_detection_rate,
        sample_rate=sample_rate,
    )
    return analyzer.analyze_from_landmarks(landmarks_sequence, file_path)

options: show_source: true heading_level: 3


PoseEngine

Pose estimation engine using MediaPipe PoseLandmarker.

This class wraps MediaPipe's PoseLandmarker to detect human pose landmarks in images. It provides a clean interface for processing individual frames and extracting landmark coordinates.

Attributes:

Name Type Description
min_detection_confidence

Minimum confidence value ([0.0, 1.0]) for pose detection to be considered successful.

min_tracking_confidence

Minimum confidence value ([0.0, 1.0]) for pose tracking to be considered successful.

landmarker

MediaPipe PoseLandmarker instance.

Source code in src/climb_sensei/pose_engine.py
class PoseEngine:
    """Pose estimation engine using MediaPipe PoseLandmarker.

    This class wraps MediaPipe's PoseLandmarker to detect human pose
    landmarks in images. It provides a clean interface for processing
    individual frames and extracting landmark coordinates.

    Attributes:
        min_detection_confidence: Minimum confidence value ([0.0, 1.0]) for
            pose detection to be considered successful.
        min_tracking_confidence: Minimum confidence value ([0.0, 1.0]) for
            pose tracking to be considered successful.
        landmarker: MediaPipe PoseLandmarker instance.
    """

    def __init__(
        self,
        min_detection_confidence: float = _DEFAULT_POSE.min_detection_confidence,
        min_tracking_confidence: float = _DEFAULT_POSE.min_tracking_confidence,
        model_path: Optional[str] = None,
    ) -> None:
        """Initialize the pose engine.

        Args:
            min_detection_confidence: Minimum confidence for detection (0.0-1.0).
            min_tracking_confidence: Minimum confidence for tracking (0.0-1.0).
            model_path: Optional path to the pose model file. If None, downloads
                       the default model automatically.
        """
        self.min_detection_confidence = min_detection_confidence
        self.min_tracking_confidence = min_tracking_confidence

        # Get model path (download if needed)
        if model_path is None:
            model_path = _get_model_path()

        # Create PoseLandmarker options with VIDEO mode for temporal smoothing
        base_options = python.BaseOptions(model_asset_path=model_path)
        options = vision.PoseLandmarkerOptions(
            base_options=base_options,
            running_mode=vision.RunningMode.VIDEO,  # VIDEO mode enables built-in temporal smoothing
            min_pose_detection_confidence=min_detection_confidence,
            min_tracking_confidence=min_tracking_confidence,
        )

        self.landmarker = vision.PoseLandmarker.create_from_options(options)
        self._last_results = None
        self._frame_timestamp_ms = 0  # Timestamp for VIDEO mode

    def process(self, image: np.ndarray) -> Optional[Any]:
        """Process an image and detect pose landmarks with temporal smoothing.

        Args:
            image: Input image in BGR format (OpenCV convention).

        Returns:
            MediaPipe pose detection results object containing landmarks,
            or None if no pose was detected.
        """
        # Convert BGR to RGB (MediaPipe expects RGB)
        image_rgb = np.ascontiguousarray(image[:, :, ::-1])

        # Create MediaPipe Image object
        mp_image = mp.Image(image_format=mp.ImageFormat.SRGB, data=image_rgb)

        # Detect pose landmarks with timestamp for VIDEO mode temporal smoothing
        # Timestamps must increase monotonically for proper temporal filtering
        results = self.landmarker.detect_for_video(mp_image, self._frame_timestamp_ms)
        self._frame_timestamp_ms += _DEFAULT_POSE.timestamp_increment_ms  # ~30fps

        # Store results for later use
        self._last_results = results if results.pose_landmarks else None

        return self._last_results

    def extract_landmarks(self, results: Any = None) -> List[Dict[str, float]]:
        """Extract landmark coordinates from pose detection results.

        Args:
            results: MediaPipe pose detection results object. If None, uses
                    the last processed results.

        Returns:
            List of dictionaries containing x, y, z coordinates and
            visibility for each landmark. Coordinates are normalized
            to [0.0, 1.0] range.
        """
        if results is None:
            results = self._last_results

        if not results or not results.pose_landmarks:
            return []

        # Get first pose (PoseLandmarker can detect multiple poses)
        pose_landmarks = results.pose_landmarks[0]

        landmarks = []
        for landmark in pose_landmarks:
            landmarks.append(
                {
                    "x": landmark.x,
                    "y": landmark.y,
                    "z": landmark.z,
                    "visibility": landmark.visibility,
                }
            )

        return landmarks

    def close(self) -> None:
        """Release MediaPipe resources."""
        self.landmarker.close()

    def __enter__(self) -> "PoseEngine":
        """Context manager entry."""
        return self

    def __exit__(self, exc_type, exc_val, exc_tb) -> None:
        """Context manager exit."""
        self.close()

__enter__()

Context manager entry.

Source code in src/climb_sensei/pose_engine.py
def __enter__(self) -> "PoseEngine":
    """Context manager entry."""
    return self

__exit__(exc_type, exc_val, exc_tb)

Context manager exit.

Source code in src/climb_sensei/pose_engine.py
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
    """Context manager exit."""
    self.close()

__init__(min_detection_confidence=_DEFAULT_POSE.min_detection_confidence, min_tracking_confidence=_DEFAULT_POSE.min_tracking_confidence, model_path=None)

Initialize the pose engine.

Parameters:

Name Type Description Default
min_detection_confidence float

Minimum confidence for detection (0.0-1.0).

min_detection_confidence
min_tracking_confidence float

Minimum confidence for tracking (0.0-1.0).

min_tracking_confidence
model_path Optional[str]

Optional path to the pose model file. If None, downloads the default model automatically.

None
Source code in src/climb_sensei/pose_engine.py
def __init__(
    self,
    min_detection_confidence: float = _DEFAULT_POSE.min_detection_confidence,
    min_tracking_confidence: float = _DEFAULT_POSE.min_tracking_confidence,
    model_path: Optional[str] = None,
) -> None:
    """Initialize the pose engine.

    Args:
        min_detection_confidence: Minimum confidence for detection (0.0-1.0).
        min_tracking_confidence: Minimum confidence for tracking (0.0-1.0).
        model_path: Optional path to the pose model file. If None, downloads
                   the default model automatically.
    """
    self.min_detection_confidence = min_detection_confidence
    self.min_tracking_confidence = min_tracking_confidence

    # Get model path (download if needed)
    if model_path is None:
        model_path = _get_model_path()

    # Create PoseLandmarker options with VIDEO mode for temporal smoothing
    base_options = python.BaseOptions(model_asset_path=model_path)
    options = vision.PoseLandmarkerOptions(
        base_options=base_options,
        running_mode=vision.RunningMode.VIDEO,  # VIDEO mode enables built-in temporal smoothing
        min_pose_detection_confidence=min_detection_confidence,
        min_tracking_confidence=min_tracking_confidence,
    )

    self.landmarker = vision.PoseLandmarker.create_from_options(options)
    self._last_results = None
    self._frame_timestamp_ms = 0  # Timestamp for VIDEO mode

close()

Release MediaPipe resources.

Source code in src/climb_sensei/pose_engine.py
def close(self) -> None:
    """Release MediaPipe resources."""
    self.landmarker.close()

extract_landmarks(results=None)

Extract landmark coordinates from pose detection results.

Parameters:

Name Type Description Default
results Any

MediaPipe pose detection results object. If None, uses the last processed results.

None

Returns:

Type Description
List[Dict[str, float]]

List of dictionaries containing x, y, z coordinates and

List[Dict[str, float]]

visibility for each landmark. Coordinates are normalized

List[Dict[str, float]]

to [0.0, 1.0] range.

Source code in src/climb_sensei/pose_engine.py
def extract_landmarks(self, results: Any = None) -> List[Dict[str, float]]:
    """Extract landmark coordinates from pose detection results.

    Args:
        results: MediaPipe pose detection results object. If None, uses
                the last processed results.

    Returns:
        List of dictionaries containing x, y, z coordinates and
        visibility for each landmark. Coordinates are normalized
        to [0.0, 1.0] range.
    """
    if results is None:
        results = self._last_results

    if not results or not results.pose_landmarks:
        return []

    # Get first pose (PoseLandmarker can detect multiple poses)
    pose_landmarks = results.pose_landmarks[0]

    landmarks = []
    for landmark in pose_landmarks:
        landmarks.append(
            {
                "x": landmark.x,
                "y": landmark.y,
                "z": landmark.z,
                "visibility": landmark.visibility,
            }
        )

    return landmarks

process(image)

Process an image and detect pose landmarks with temporal smoothing.

Parameters:

Name Type Description Default
image ndarray

Input image in BGR format (OpenCV convention).

required

Returns:

Type Description
Optional[Any]

MediaPipe pose detection results object containing landmarks,

Optional[Any]

or None if no pose was detected.

Source code in src/climb_sensei/pose_engine.py
def process(self, image: np.ndarray) -> Optional[Any]:
    """Process an image and detect pose landmarks with temporal smoothing.

    Args:
        image: Input image in BGR format (OpenCV convention).

    Returns:
        MediaPipe pose detection results object containing landmarks,
        or None if no pose was detected.
    """
    # Convert BGR to RGB (MediaPipe expects RGB)
    image_rgb = np.ascontiguousarray(image[:, :, ::-1])

    # Create MediaPipe Image object
    mp_image = mp.Image(image_format=mp.ImageFormat.SRGB, data=image_rgb)

    # Detect pose landmarks with timestamp for VIDEO mode temporal smoothing
    # Timestamps must increase monotonically for proper temporal filtering
    results = self.landmarker.detect_for_video(mp_image, self._frame_timestamp_ms)
    self._frame_timestamp_ms += _DEFAULT_POSE.timestamp_increment_ms  # ~30fps

    # Store results for later use
    self._last_results = results if results.pose_landmarks else None

    return self._last_results

options: show_source: true heading_level: 3


VideoReader

Read video frames from a file or camera source.

Attributes:

Name Type Description
path

Path to video file or camera index.

cap

OpenCV VideoCapture object.

fps

Frames per second of the video.

frame_count

Total number of frames in the video.

width

Width of video frames in pixels.

height

Height of video frames in pixels.

Source code in src/climb_sensei/video_io.py
class VideoReader:
    """Read video frames from a file or camera source.

    Attributes:
        path: Path to video file or camera index.
        cap: OpenCV VideoCapture object.
        fps: Frames per second of the video.
        frame_count: Total number of frames in the video.
        width: Width of video frames in pixels.
        height: Height of video frames in pixels.
    """

    def __init__(self, path: str | int) -> None:
        """Initialize the video reader.

        Args:
            path: Path to video file or camera index (0 for default camera).

        Raises:
            ValueError: If the video source cannot be opened.
        """
        self.path = path
        self.cap = cv2.VideoCapture(path)

        if not self.cap.isOpened():
            raise ValueError(f"Cannot open video source: {path}")

        self.fps = int(self.cap.get(cv2.CAP_PROP_FPS))
        self.frame_count = int(self.cap.get(cv2.CAP_PROP_FRAME_COUNT))
        self.width = int(self.cap.get(cv2.CAP_PROP_FRAME_WIDTH))
        self.height = int(self.cap.get(cv2.CAP_PROP_FRAME_HEIGHT))

    def read(self) -> Tuple[bool, Optional[np.ndarray]]:
        """Read the next frame from the video.

        Returns:
            A tuple of (success, frame) where success is True if frame was read
            successfully, and frame is the image data as a numpy array.
        """
        success, frame = self.cap.read()
        return success, frame if success else None

    def __iter__(self) -> Iterator[np.ndarray]:
        """Iterate over video frames.

        Yields:
            Each frame as a numpy array (BGR format).

        Example:
            >>> with VideoReader("video.mp4") as reader:
            ...     for frame in reader:
            ...         process(frame)
        """
        while True:
            success, frame = self.cap.read()
            if not success:
                return
            yield frame

    def __len__(self) -> int:
        """Return the total number of frames in the video."""
        return self.frame_count

    def release(self) -> None:
        """Release the video capture resource."""
        self.cap.release()

    def __enter__(self) -> "VideoReader":
        """Context manager entry."""
        return self

    def __exit__(self, exc_type, exc_val, exc_tb) -> None:
        """Context manager exit."""
        self.release()

__enter__()

Context manager entry.

Source code in src/climb_sensei/video_io.py
def __enter__(self) -> "VideoReader":
    """Context manager entry."""
    return self

__exit__(exc_type, exc_val, exc_tb)

Context manager exit.

Source code in src/climb_sensei/video_io.py
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
    """Context manager exit."""
    self.release()

__init__(path)

Initialize the video reader.

Parameters:

Name Type Description Default
path str | int

Path to video file or camera index (0 for default camera).

required

Raises:

Type Description
ValueError

If the video source cannot be opened.

Source code in src/climb_sensei/video_io.py
def __init__(self, path: str | int) -> None:
    """Initialize the video reader.

    Args:
        path: Path to video file or camera index (0 for default camera).

    Raises:
        ValueError: If the video source cannot be opened.
    """
    self.path = path
    self.cap = cv2.VideoCapture(path)

    if not self.cap.isOpened():
        raise ValueError(f"Cannot open video source: {path}")

    self.fps = int(self.cap.get(cv2.CAP_PROP_FPS))
    self.frame_count = int(self.cap.get(cv2.CAP_PROP_FRAME_COUNT))
    self.width = int(self.cap.get(cv2.CAP_PROP_FRAME_WIDTH))
    self.height = int(self.cap.get(cv2.CAP_PROP_FRAME_HEIGHT))

__iter__()

Iterate over video frames.

Yields:

Type Description
ndarray

Each frame as a numpy array (BGR format).

Example

with VideoReader("video.mp4") as reader: ... for frame in reader: ... process(frame)

Source code in src/climb_sensei/video_io.py
def __iter__(self) -> Iterator[np.ndarray]:
    """Iterate over video frames.

    Yields:
        Each frame as a numpy array (BGR format).

    Example:
        >>> with VideoReader("video.mp4") as reader:
        ...     for frame in reader:
        ...         process(frame)
    """
    while True:
        success, frame = self.cap.read()
        if not success:
            return
        yield frame

__len__()

Return the total number of frames in the video.

Source code in src/climb_sensei/video_io.py
def __len__(self) -> int:
    """Return the total number of frames in the video."""
    return self.frame_count

read()

Read the next frame from the video.

Returns:

Type Description
bool

A tuple of (success, frame) where success is True if frame was read

Optional[ndarray]

successfully, and frame is the image data as a numpy array.

Source code in src/climb_sensei/video_io.py
def read(self) -> Tuple[bool, Optional[np.ndarray]]:
    """Read the next frame from the video.

    Returns:
        A tuple of (success, frame) where success is True if frame was read
        successfully, and frame is the image data as a numpy array.
    """
    success, frame = self.cap.read()
    return success, frame if success else None

release()

Release the video capture resource.

Source code in src/climb_sensei/video_io.py
def release(self) -> None:
    """Release the video capture resource."""
    self.cap.release()

options: show_source: true heading_level: 3


VideoWriter

Write video frames to a file.

Attributes:

Name Type Description
path

Output file path.

fourcc

FourCC codec code.

fps

Frames per second for output video.

width

Width of output frames in pixels.

height

Height of output frames in pixels.

writer

OpenCV VideoWriter object.

Source code in src/climb_sensei/video_io.py
class VideoWriter:
    """Write video frames to a file.

    Attributes:
        path: Output file path.
        fourcc: FourCC codec code.
        fps: Frames per second for output video.
        width: Width of output frames in pixels.
        height: Height of output frames in pixels.
        writer: OpenCV VideoWriter object.
    """

    def __init__(
        self, path: str, fps: int, width: int, height: int, fourcc: str = "avc1"
    ) -> None:
        """Initialize the video writer.

        Args:
            path: Output file path.
            fps: Frames per second for the output video.
            width: Width of output frames in pixels.
            height: Height of output frames in pixels.
            fourcc: FourCC codec code (default: "avc1" for H.264, browser-compatible).

        Raises:
            ValueError: If the video writer cannot be initialized.
        """
        self.path = path
        self.fps = fps
        self.width = width
        self.height = height
        self.fourcc = cv2.VideoWriter_fourcc(*fourcc)

        self.writer = cv2.VideoWriter(path, self.fourcc, fps, (width, height))

        if not self.writer.isOpened():
            raise ValueError(f"Cannot open video writer for: {path}")

    def write(self, frame: np.ndarray) -> None:
        """Write a frame to the video file.

        Args:
            frame: Image data as a numpy array (BGR format).
        """
        self.writer.write(frame)

    def release(self) -> None:
        """Release the video writer resource."""
        self.writer.release()

    def __enter__(self) -> "VideoWriter":
        """Context manager entry."""
        return self

    def __exit__(self, exc_type, exc_val, exc_tb) -> None:
        """Context manager exit."""
        self.release()

__enter__()

Context manager entry.

Source code in src/climb_sensei/video_io.py
def __enter__(self) -> "VideoWriter":
    """Context manager entry."""
    return self

__exit__(exc_type, exc_val, exc_tb)

Context manager exit.

Source code in src/climb_sensei/video_io.py
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
    """Context manager exit."""
    self.release()

__init__(path, fps, width, height, fourcc='avc1')

Initialize the video writer.

Parameters:

Name Type Description Default
path str

Output file path.

required
fps int

Frames per second for the output video.

required
width int

Width of output frames in pixels.

required
height int

Height of output frames in pixels.

required
fourcc str

FourCC codec code (default: "avc1" for H.264, browser-compatible).

'avc1'

Raises:

Type Description
ValueError

If the video writer cannot be initialized.

Source code in src/climb_sensei/video_io.py
def __init__(
    self, path: str, fps: int, width: int, height: int, fourcc: str = "avc1"
) -> None:
    """Initialize the video writer.

    Args:
        path: Output file path.
        fps: Frames per second for the output video.
        width: Width of output frames in pixels.
        height: Height of output frames in pixels.
        fourcc: FourCC codec code (default: "avc1" for H.264, browser-compatible).

    Raises:
        ValueError: If the video writer cannot be initialized.
    """
    self.path = path
    self.fps = fps
    self.width = width
    self.height = height
    self.fourcc = cv2.VideoWriter_fourcc(*fourcc)

    self.writer = cv2.VideoWriter(path, self.fourcc, fps, (width, height))

    if not self.writer.isOpened():
        raise ValueError(f"Cannot open video writer for: {path}")

release()

Release the video writer resource.

Source code in src/climb_sensei/video_io.py
def release(self) -> None:
    """Release the video writer resource."""
    self.writer.release()

write(frame)

Write a frame to the video file.

Parameters:

Name Type Description Default
frame ndarray

Image data as a numpy array (BGR format).

required
Source code in src/climb_sensei/video_io.py
def write(self, frame: np.ndarray) -> None:
    """Write a frame to the video file.

    Args:
        frame: Image data as a numpy array (BGR format).
    """
    self.writer.write(frame)

options: show_source: true heading_level: 3


Biomechanics Functions

calculate_joint_angle

Calculate the angle at point B formed by three points A-B-C.

This function calculates the interior angle at the middle point (B) using the law of cosines. The angle is measured in degrees.

Parameters:

Name Type Description Default
point_a Tuple[float, float]

Coordinates (x, y) of the first point.

required
point_b Tuple[float, float]

Coordinates (x, y) of the vertex point (angle point).

required
point_c Tuple[float, float]

Coordinates (x, y) of the third point.

required

Returns:

Type Description
float

Angle in degrees at point B (range: 0-180).

Example

Calculate elbow angle

shoulder = (0.5, 0.3) elbow = (0.6, 0.5) wrist = (0.7, 0.6) angle = calculate_joint_angle(shoulder, elbow, wrist)

Source code in src/climb_sensei/biomechanics.py
def calculate_joint_angle(
    point_a: Tuple[float, float],
    point_b: Tuple[float, float],
    point_c: Tuple[float, float],
) -> float:
    """Calculate the angle at point B formed by three points A-B-C.

    This function calculates the interior angle at the middle point (B)
    using the law of cosines. The angle is measured in degrees.

    Args:
        point_a: Coordinates (x, y) of the first point.
        point_b: Coordinates (x, y) of the vertex point (angle point).
        point_c: Coordinates (x, y) of the third point.

    Returns:
        Angle in degrees at point B (range: 0-180).

    Example:
        >>> # Calculate elbow angle
        >>> shoulder = (0.5, 0.3)
        >>> elbow = (0.6, 0.5)
        >>> wrist = (0.7, 0.6)
        >>> angle = calculate_joint_angle(shoulder, elbow, wrist)
    """
    # Calculate vectors BA and BC
    ba_x = point_a[0] - point_b[0]
    ba_y = point_a[1] - point_b[1]
    bc_x = point_c[0] - point_b[0]
    bc_y = point_c[1] - point_b[1]

    # Calculate dot product
    dot_product = ba_x * bc_x + ba_y * bc_y

    # Calculate magnitudes
    magnitude_ba = math.sqrt(ba_x**2 + ba_y**2)
    magnitude_bc = math.sqrt(bc_x**2 + bc_y**2)

    # Avoid division by zero
    if magnitude_ba == 0 or magnitude_bc == 0:
        return 0.0

    # Calculate angle using dot product formula
    cos_angle = dot_product / (magnitude_ba * magnitude_bc)

    # Clamp to valid range for arccos
    cos_angle = max(-1.0, min(1.0, cos_angle))

    # Convert to degrees
    angle_radians = math.acos(cos_angle)
    angle_degrees = math.degrees(angle_radians)

    return angle_degrees

options: show_source: true heading_level: 4

calculate_reach_distance

Calculate Euclidean distance between two points.

This function calculates the straight-line distance between two points in 2D space. Useful for measuring reach distances between body landmarks.

Parameters:

Name Type Description Default
point_a Tuple[float, float]

Coordinates (x, y) of the first point.

required
point_b Tuple[float, float]

Coordinates (x, y) of the second point.

required

Returns:

Type Description
float

Euclidean distance between the two points.

Example

Calculate reach from hip to hand

hip = (0.5, 0.5) hand = (0.7, 0.3) distance = calculate_reach_distance(hip, hand)

Source code in src/climb_sensei/biomechanics.py
def calculate_reach_distance(
    point_a: Tuple[float, float], point_b: Tuple[float, float]
) -> float:
    """Calculate Euclidean distance between two points.

    This function calculates the straight-line distance between two
    points in 2D space. Useful for measuring reach distances between
    body landmarks.

    Args:
        point_a: Coordinates (x, y) of the first point.
        point_b: Coordinates (x, y) of the second point.

    Returns:
        Euclidean distance between the two points.

    Example:
        >>> # Calculate reach from hip to hand
        >>> hip = (0.5, 0.5)
        >>> hand = (0.7, 0.3)
        >>> distance = calculate_reach_distance(hip, hand)
    """
    dx = point_b[0] - point_a[0]
    dy = point_b[1] - point_a[1]

    distance = math.sqrt(dx**2 + dy**2)

    return distance

options: show_source: true heading_level: 4

calculate_center_of_mass

Calculate the weighted center of mass for a set of points.

Parameters:

Name Type Description Default
points list[Tuple[float, float]]

List of (x, y) coordinate tuples.

required
weights list[float] | None

Optional list of weights for each point. If None, all points are weighted equally.

None

Returns:

Type Description
Tuple[float, float]

Coordinates (x, y) of the center of mass.

Raises:

Type Description
ValueError

If points list is empty or weights length doesn't match.

Example

Calculate body center of mass

landmarks = [(0.5, 0.3), (0.5, 0.5), (0.5, 0.7)] center = calculate_center_of_mass(landmarks)

Source code in src/climb_sensei/biomechanics.py
def calculate_center_of_mass(
    points: list[Tuple[float, float]], weights: list[float] | None = None
) -> Tuple[float, float]:
    """Calculate the weighted center of mass for a set of points.

    Args:
        points: List of (x, y) coordinate tuples.
        weights: Optional list of weights for each point. If None, all
            points are weighted equally.

    Returns:
        Coordinates (x, y) of the center of mass.

    Raises:
        ValueError: If points list is empty or weights length doesn't match.

    Example:
        >>> # Calculate body center of mass
        >>> landmarks = [(0.5, 0.3), (0.5, 0.5), (0.5, 0.7)]
        >>> center = calculate_center_of_mass(landmarks)
    """
    if not points:
        raise ValueError("Points list cannot be empty")

    if weights is None:
        weights = [1.0] * len(points)

    if len(points) != len(weights):
        raise ValueError("Points and weights must have the same length")

    total_weight = sum(weights)
    if total_weight == 0:
        raise ValueError("Total weight cannot be zero")

    weighted_x = sum(p[0] * w for p, w in zip(points, weights))
    weighted_y = sum(p[1] * w for p, w in zip(points, weights))

    center_x = weighted_x / total_weight
    center_y = weighted_y / total_weight

    return (center_x, center_y)

options: show_source: true heading_level: 4

calculate_limb_angles

Calculate joint angles for all major limbs.

Parameters:

Name Type Description Default
landmarks list[dict[str, float]]

List of landmark dictionaries with x, y, z coordinates

required
landmark_indices type[LandmarkIndex]

LandmarkIndex class with landmark indices

required

Returns:

Type Description
dict[str, float]

Dictionary with joint angles:

dict[str, float]
  • left_elbow: Left elbow angle (degrees)
dict[str, float]
  • right_elbow: Right elbow angle (degrees)
dict[str, float]
  • left_shoulder: Left shoulder angle (degrees)
dict[str, float]
  • right_shoulder: Right shoulder angle (degrees)
dict[str, float]
  • left_knee: Left knee angle (degrees)
dict[str, float]
  • right_knee: Right knee angle (degrees)
dict[str, float]
  • left_hip: Left hip angle (degrees)
dict[str, float]
  • right_hip: Right hip angle (degrees)
Source code in src/climb_sensei/biomechanics.py
def calculate_limb_angles(
    landmarks: list[dict[str, float]], landmark_indices: type[LandmarkIndex]
) -> dict[str, float]:
    """Calculate joint angles for all major limbs.

    Args:
        landmarks: List of landmark dictionaries with x, y, z coordinates
        landmark_indices: LandmarkIndex class with landmark indices

    Returns:
        Dictionary with joint angles:
        - left_elbow: Left elbow angle (degrees)
        - right_elbow: Right elbow angle (degrees)
        - left_shoulder: Left shoulder angle (degrees)
        - right_shoulder: Right shoulder angle (degrees)
        - left_knee: Left knee angle (degrees)
        - right_knee: Right knee angle (degrees)
        - left_hip: Left hip angle (degrees)
        - right_hip: Right hip angle (degrees)
    """
    if len(landmarks) < 33:
        return {}

    angles = {}

    # Elbow angles
    angles["left_elbow"] = calculate_joint_angle(
        (
            landmarks[landmark_indices.LEFT_SHOULDER]["x"],
            landmarks[landmark_indices.LEFT_SHOULDER]["y"],
        ),
        (
            landmarks[landmark_indices.LEFT_ELBOW]["x"],
            landmarks[landmark_indices.LEFT_ELBOW]["y"],
        ),
        (
            landmarks[landmark_indices.LEFT_WRIST]["x"],
            landmarks[landmark_indices.LEFT_WRIST]["y"],
        ),
    )

    angles["right_elbow"] = calculate_joint_angle(
        (
            landmarks[landmark_indices.RIGHT_SHOULDER]["x"],
            landmarks[landmark_indices.RIGHT_SHOULDER]["y"],
        ),
        (
            landmarks[landmark_indices.RIGHT_ELBOW]["x"],
            landmarks[landmark_indices.RIGHT_ELBOW]["y"],
        ),
        (
            landmarks[landmark_indices.RIGHT_WRIST]["x"],
            landmarks[landmark_indices.RIGHT_WRIST]["y"],
        ),
    )

    # Shoulder angles
    angles["left_shoulder"] = calculate_joint_angle(
        (
            landmarks[landmark_indices.LEFT_HIP]["x"],
            landmarks[landmark_indices.LEFT_HIP]["y"],
        ),
        (
            landmarks[landmark_indices.LEFT_SHOULDER]["x"],
            landmarks[landmark_indices.LEFT_SHOULDER]["y"],
        ),
        (
            landmarks[landmark_indices.LEFT_ELBOW]["x"],
            landmarks[landmark_indices.LEFT_ELBOW]["y"],
        ),
    )

    angles["right_shoulder"] = calculate_joint_angle(
        (
            landmarks[landmark_indices.RIGHT_HIP]["x"],
            landmarks[landmark_indices.RIGHT_HIP]["y"],
        ),
        (
            landmarks[landmark_indices.RIGHT_SHOULDER]["x"],
            landmarks[landmark_indices.RIGHT_SHOULDER]["y"],
        ),
        (
            landmarks[landmark_indices.RIGHT_ELBOW]["x"],
            landmarks[landmark_indices.RIGHT_ELBOW]["y"],
        ),
    )

    # Knee angles
    angles["left_knee"] = calculate_joint_angle(
        (
            landmarks[landmark_indices.LEFT_HIP]["x"],
            landmarks[landmark_indices.LEFT_HIP]["y"],
        ),
        (
            landmarks[landmark_indices.LEFT_KNEE]["x"],
            landmarks[landmark_indices.LEFT_KNEE]["y"],
        ),
        (
            landmarks[landmark_indices.LEFT_ANKLE]["x"],
            landmarks[landmark_indices.LEFT_ANKLE]["y"],
        ),
    )

    angles["right_knee"] = calculate_joint_angle(
        (
            landmarks[landmark_indices.RIGHT_HIP]["x"],
            landmarks[landmark_indices.RIGHT_HIP]["y"],
        ),
        (
            landmarks[landmark_indices.RIGHT_KNEE]["x"],
            landmarks[landmark_indices.RIGHT_KNEE]["y"],
        ),
        (
            landmarks[landmark_indices.RIGHT_ANKLE]["x"],
            landmarks[landmark_indices.RIGHT_ANKLE]["y"],
        ),
    )

    # Hip angles
    angles["left_hip"] = calculate_joint_angle(
        (
            landmarks[landmark_indices.LEFT_SHOULDER]["x"],
            landmarks[landmark_indices.LEFT_SHOULDER]["y"],
        ),
        (
            landmarks[landmark_indices.LEFT_HIP]["x"],
            landmarks[landmark_indices.LEFT_HIP]["y"],
        ),
        (
            landmarks[landmark_indices.LEFT_KNEE]["x"],
            landmarks[landmark_indices.LEFT_KNEE]["y"],
        ),
    )

    angles["right_hip"] = calculate_joint_angle(
        (
            landmarks[landmark_indices.RIGHT_SHOULDER]["x"],
            landmarks[landmark_indices.RIGHT_SHOULDER]["y"],
        ),
        (
            landmarks[landmark_indices.RIGHT_HIP]["x"],
            landmarks[landmark_indices.RIGHT_HIP]["y"],
        ),
        (
            landmarks[landmark_indices.RIGHT_KNEE]["x"],
            landmarks[landmark_indices.RIGHT_KNEE]["y"],
        ),
    )

    return angles

options: show_source: true heading_level: 4

calculate_total_distance_traveled

Calculate total distance traveled along a path.

Parameters:

Name Type Description Default
positions list[Tuple[float, float]]

List of (x, y) positions in chronological order

required

Returns:

Type Description
float

Total distance traveled (sum of all segments)

Source code in src/climb_sensei/biomechanics.py
def calculate_total_distance_traveled(positions: list[Tuple[float, float]]) -> float:
    """Calculate total distance traveled along a path.

    Args:
        positions: List of (x, y) positions in chronological order

    Returns:
        Total distance traveled (sum of all segments)
    """
    if len(positions) < 2:
        return 0.0

    total_distance = 0.0
    for i in range(1, len(positions)):
        total_distance += calculate_reach_distance(positions[i - 1], positions[i])

    return total_distance

options: show_source: true heading_level: 4


Visualization Functions

draw_pose_landmarks

Draw pose landmarks and connections on an image with color-coded body parts.

This function draws the detected pose landmarks as circles and connects them with lines. By default, uses color-coding for different body parts (similar to MediaPipe's built-in visualization).

Parameters:

Name Type Description Default
image ndarray

Input image in BGR format (will not be modified).

required
results Any

MediaPipe pose detection results object.

required
landmark_color Optional[Tuple[int, int, int]]

BGR color tuple for landmarks. If None and use_color_coding=True, uses automatic color-coding by body part.

None
connection_color Optional[Tuple[int, int, int]]

BGR color tuple for connections. If None, uses white.

None
thickness int

Line thickness in pixels (default: 2).

line_thickness
circle_radius int

Landmark circle radius in pixels (default: 5).

circle_radius
connections Optional[FrozenSet[Tuple[int, int]]]

Optional set of (start_idx, end_idx) tuples defining which landmarks to connect. If None, uses default full pose connections.

None
landmarks_to_draw Optional[FrozenSet[int]]

Optional set of landmark indices to draw. If None, draws all.

None
use_color_coding bool

If True, uses color-coded landmarks by body part (default: True).

True

Returns:

Type Description
ndarray

New image with landmarks drawn (BGR format).

Example

from climb_sensei.config import CLIMBING_CONNECTIONS, CLIMBING_LANDMARKS with PoseEngine() as engine: ... results = engine.process(frame) ... if results: ... # Draw only climbing-relevant landmarks (no head) with color-coding ... annotated_frame = draw_pose_landmarks( ... frame, results, ... connections=CLIMBING_CONNECTIONS, ... landmarks_to_draw=CLIMBING_LANDMARKS ... )

Source code in src/climb_sensei/viz.py
def draw_pose_landmarks(
    image: np.ndarray,
    results: Any,
    landmark_color: Optional[Tuple[int, int, int]] = None,
    connection_color: Optional[Tuple[int, int, int]] = None,
    thickness: int = VIZ.line_thickness,
    circle_radius: int = VIZ.circle_radius,
    connections: Optional[FrozenSet[Tuple[int, int]]] = None,
    landmarks_to_draw: Optional[FrozenSet[int]] = None,
    use_color_coding: bool = True,
) -> np.ndarray:
    """Draw pose landmarks and connections on an image with color-coded body parts.

    This function draws the detected pose landmarks as circles and
    connects them with lines. By default, uses color-coding for different
    body parts (similar to MediaPipe's built-in visualization).

    Args:
        image: Input image in BGR format (will not be modified).
        results: MediaPipe pose detection results object.
        landmark_color: BGR color tuple for landmarks. If None and use_color_coding=True,
                       uses automatic color-coding by body part.
        connection_color: BGR color tuple for connections. If None, uses white.
        thickness: Line thickness in pixels (default: 2).
        circle_radius: Landmark circle radius in pixels (default: 5).
        connections: Optional set of (start_idx, end_idx) tuples defining which
                    landmarks to connect. If None, uses default full pose connections.
        landmarks_to_draw: Optional set of landmark indices to draw. If None, draws all.
        use_color_coding: If True, uses color-coded landmarks by body part (default: True).

    Returns:
        New image with landmarks drawn (BGR format).

    Example:
        >>> from climb_sensei.config import CLIMBING_CONNECTIONS, CLIMBING_LANDMARKS
        >>> with PoseEngine() as engine:
        ...     results = engine.process(frame)
        ...     if results:
        ...         # Draw only climbing-relevant landmarks (no head) with color-coding
        ...         annotated_frame = draw_pose_landmarks(
        ...             frame, results,
        ...             connections=CLIMBING_CONNECTIONS,
        ...             landmarks_to_draw=CLIMBING_LANDMARKS
        ...         )
    """
    # Create a copy to avoid modifying the original
    annotated_image = image.copy()

    if not results or not results.pose_landmarks:
        return annotated_image

    # Use default connections if none provided
    if connections is None:
        connections = _POSE_CONNECTIONS

    # Default connection color
    if connection_color is None:
        connection_color = COLORS["connection"]

    # Extract landmarks from results
    pose_landmarks = results.pose_landmarks[0]
    h, w = image.shape[:2]

    # Convert landmarks to pixel coordinates
    landmark_points = []
    for landmark in pose_landmarks:
        x = int(landmark.x * w)
        y = int(landmark.y * h)
        landmark_points.append((x, y))

    # Draw connections
    for connection in connections:
        start_idx, end_idx = connection
        # Only draw connection if both landmarks are in the filter set (or no filter)
        if landmarks_to_draw is None or (
            start_idx in landmarks_to_draw and end_idx in landmarks_to_draw
        ):
            if start_idx < len(landmark_points) and end_idx < len(landmark_points):
                start_point = landmark_points[start_idx]
                end_point = landmark_points[end_idx]
                cv2.line(
                    annotated_image, start_point, end_point, connection_color, thickness
                )

    # Draw landmarks
    for idx, point in enumerate(landmark_points):
        # Only draw if landmark is in the set to draw (or if no filter is specified)
        if landmarks_to_draw is None or idx in landmarks_to_draw:
            # Determine color
            if use_color_coding and landmark_color is None:
                color = _get_landmark_color(idx)
            else:
                color = landmark_color if landmark_color else (0, 255, 0)

            cv2.circle(annotated_image, point, circle_radius, color, -1)
            # Add small border for better visibility
            cv2.circle(
                annotated_image,
                point,
                circle_radius,
                COLORS["connection"],
                VIZ.landmark_border_thickness,
            )

    return annotated_image

options: show_source: true heading_level: 4

draw_metrics_overlay

Draw climbing metrics overlay on image.

Parameters:

Name Type Description Default
image ndarray

Input image in BGR format (will not be modified).

required
current_metrics Optional[dict]

Dictionary of current frame metrics.

None
cumulative_metrics Optional[dict]

Dictionary of cumulative/average metrics.

None
font_scale float

Font scale factor (default: 0.6).

font_scale
thickness int

Text thickness in pixels (default: 2).

font_thickness
bg_alpha float

Background transparency (0.0-1.0, default: 0.7).

metrics_overlay_bg_alpha

Returns:

Type Description
ndarray

New image with metrics overlay drawn (BGR format).

Source code in src/climb_sensei/viz.py
def draw_metrics_overlay(
    image: np.ndarray,
    current_metrics: Optional[dict] = None,
    cumulative_metrics: Optional[dict] = None,
    font_scale: float = VIZ.font_scale,
    thickness: int = VIZ.font_thickness,
    bg_alpha: float = VIZ.metrics_overlay_bg_alpha,
) -> np.ndarray:
    """Draw climbing metrics overlay on image.

    Args:
        image: Input image in BGR format (will not be modified).
        current_metrics: Dictionary of current frame metrics.
        cumulative_metrics: Dictionary of cumulative/average metrics.
        font_scale: Font scale factor (default: 0.6).
        thickness: Text thickness in pixels (default: 2).
        bg_alpha: Background transparency (0.0-1.0, default: 0.7).

    Returns:
        New image with metrics overlay drawn (BGR format).
    """
    annotated_image = image.copy()
    h, w = image.shape[:2]

    font = cv2.FONT_HERSHEY_SIMPLEX
    line_height = int(VIZ.metrics_line_height * font_scale)
    padding = VIZ.metrics_overlay_padding

    # Prepare text lines
    lines = []

    if current_metrics:
        lines.append(("CURRENT FRAME", (0, 255, 255)))  # Yellow
        lines.append((f"Frame: {current_metrics.get('frame', 0)}", (255, 255, 255)))
        lines.append(
            (
                f"L Elbow: {current_metrics.get('left_elbow_angle', 0):.1f}°",
                (100, 255, 100),
            )
        )
        lines.append(
            (
                f"R Elbow: {current_metrics.get('right_elbow_angle', 0):.1f}°",
                (100, 255, 100),
            )
        )
        lines.append(
            (
                f"L Knee: {current_metrics.get('left_knee_angle', 0):.1f}°",
                (100, 200, 255),
            )
        )
        lines.append(
            (
                f"R Knee: {current_metrics.get('right_knee_angle', 0):.1f}°",
                (100, 200, 255),
            )
        )
        lines.append(
            (f"Max Reach: {current_metrics.get('max_reach', 0):.3f}", (255, 150, 100))
        )

        # Lock-off indicators
        if current_metrics.get("left_lock_off"):
            lines.append(("L LOCK-OFF", (0, 0, 255)))
        if current_metrics.get("right_lock_off"):
            lines.append(("R LOCK-OFF", (0, 0, 255)))

    if cumulative_metrics and lines:
        lines.append(("", (255, 255, 255)))  # Spacer

    if cumulative_metrics:
        lines.append(("AVERAGES", (0, 255, 255)))  # Yellow
        lines.append(
            (
                f"L Elbow: {cumulative_metrics.get('avg_left_elbow', 0):.1f}°",
                (150, 255, 150),
            )
        )
        lines.append(
            (
                f"R Elbow: {cumulative_metrics.get('avg_right_elbow', 0):.1f}°",
                (150, 255, 150),
            )
        )
        lines.append(
            (
                f"Max Reach: {cumulative_metrics.get('avg_max_reach', 0):.3f}",
                (255, 180, 150),
            )
        )
        lines.append(
            (
                f"Extension: {cumulative_metrics.get('avg_extension', 0):.3f}",
                (200, 200, 255),
            )
        )

    if not lines:
        return annotated_image

    # Calculate overlay dimensions
    max_text_width = 0
    for text, _ in lines:
        if text:
            (text_w, text_h), _ = cv2.getTextSize(text, font, font_scale, thickness)
            max_text_width = max(max_text_width, text_w)

    overlay_width = max_text_width + 2 * padding
    overlay_height = len(lines) * line_height + 2 * padding

    # Create semi-transparent background
    overlay = annotated_image.copy()
    x1, y1 = padding, padding
    x2, y2 = x1 + overlay_width, y1 + overlay_height

    cv2.rectangle(overlay, (x1, y1), (x2, y2), (0, 0, 0), -1)
    cv2.addWeighted(
        overlay, bg_alpha, annotated_image, 1 - bg_alpha, 0, annotated_image
    )

    # Draw text
    y_offset = y1 + padding + line_height
    for text, color in lines:
        if text:  # Skip empty lines for spacing
            cv2.putText(
                annotated_image,
                text,
                (x1 + padding, y_offset),
                font,
                font_scale,
                color,
                thickness,
                cv2.LINE_AA,
            )
        y_offset += line_height

    return annotated_image

options: show_source: true heading_level: 4

create_metrics_dashboard

Create a dashboard with multiple metric plots.

Parameters:

Name Type Description Default
history Dict[str, List[float]]

Dictionary with metric histories from ClimbingAnalyzer.get_history()

required
current_frame int

Current frame index

required
fps float

Frames per second (for time axis)

30.0
plot_width int

Width of each plot

plot_width
plot_height int

Height of each plot

plot_height

Returns:

Type Description
ndarray

Dashboard image as numpy array (BGR format)

Source code in src/climb_sensei/metrics_viz.py
def create_metrics_dashboard(
    history: Dict[str, List[float]],
    current_frame: int,
    fps: float = 30.0,
    plot_width: int = VIZ.plot_width,
    plot_height: int = VIZ.plot_height,
) -> np.ndarray:
    """Create a dashboard with multiple metric plots.

    Args:
        history: Dictionary with metric histories from ClimbingAnalyzer.get_history()
        current_frame: Current frame index
        fps: Frames per second (for time axis)
        plot_width: Width of each plot
        plot_height: Height of each plot

    Returns:
        Dashboard image as numpy array (BGR format)
    """
    plots = []

    # Vertical progress (inverted - climbing up)
    if history.get("hip_heights"):
        hip_heights = history["hip_heights"]
        initial = hip_heights[0] if hip_heights else 0
        progress = [initial - h for h in hip_heights]
        plot = create_metric_plot(
            progress,
            current_frame,
            plot_width,
            plot_height,
            title="Vertical Progress",
            color=(0, 255, 255),
            y_label="height",
            min_val=0,
        )
        plots.append(plot)

    # Velocity
    if history.get("velocities"):
        plot = create_metric_plot(
            history["velocities"],
            current_frame,
            plot_width,
            plot_height,
            title="Movement Speed",
            color=(0, 255, 0),
            y_label="vel",
            min_val=0,
        )
        plots.append(plot)

    # Stability (sway)
    if history.get("sways"):
        plot = create_metric_plot(
            history["sways"],
            current_frame,
            plot_width,
            plot_height,
            title="Lateral Sway (stability)",
            color=(255, 128, 0),
            y_label="sway",
            min_val=0,
        )
        plots.append(plot)

    # Smoothness (jerk - lower is better)
    if history.get("jerks"):
        plot = create_metric_plot(
            history["jerks"],
            current_frame,
            plot_width,
            plot_height,
            title="Jerk (smoothness)",
            color=(255, 0, 255),
            y_label="jerk",
            min_val=0,
        )
        plots.append(plot)

    # Body angle
    if history.get("body_angles"):
        plot = create_metric_plot(
            history["body_angles"],
            current_frame,
            plot_width,
            plot_height,
            title="Body Angle (lean)",
            color=(128, 128, 255),
            y_label="deg",
        )
        plots.append(plot)

    # Movement Economy
    if history.get("movement_economy"):
        plot = create_metric_plot(
            history["movement_economy"],
            current_frame,
            plot_width,
            plot_height,
            title="Movement Economy (efficiency)",
            color=(0, 200, 100),
            y_label="ratio",
            min_val=0,
        )
        plots.append(plot)

    # Lock-offs (boolean visualized as 0/1)
    if history.get("lock_offs"):
        plot = create_metric_plot(
            history["lock_offs"],
            current_frame,
            plot_width,
            plot_height,
            title="Lock-off Positions",
            color=(255, 100, 0),
            y_label="active",
            min_val=0,
            max_val=1,
        )
        plots.append(plot)

    # Fatigue indicator (show in early plots for visibility)
    # We'll calculate a simple rolling average of jerk to show fatigue trend

    # Hand span
    if history.get("hand_spans"):
        plot = create_metric_plot(
            history["hand_spans"],
            current_frame,
            plot_width,
            plot_height,
            title="Hand Span",
            color=(0, 128, 255),
            y_label="span",
            min_val=0,
        )
        plots.append(plot)

    # Stack plots vertically
    if plots:
        dashboard = np.vstack(plots)
    else:
        dashboard = np.zeros((100, plot_width, 3), dtype=np.uint8)

    return dashboard

options: show_source: true heading_level: 4

compose_frame_with_dashboard

Compose video frame side-by-side with metrics dashboard (no overlay).

Parameters:

Name Type Description Default
frame ndarray

Input video frame

required
dashboard ndarray

Metrics dashboard image

required
position str

Where to place dashboard ("right" or "left")

'right'
spacing int

Pixels of spacing between frame and dashboard

0

Returns:

Type Description
ndarray

Composite frame with video and dashboard side-by-side

Source code in src/climb_sensei/metrics_viz.py
def compose_frame_with_dashboard(
    frame: np.ndarray,
    dashboard: np.ndarray,
    position: str = "right",
    spacing: int = 0,
) -> np.ndarray:
    """Compose video frame side-by-side with metrics dashboard (no overlay).

    Args:
        frame: Input video frame
        dashboard: Metrics dashboard image
        position: Where to place dashboard ("right" or "left")
        spacing: Pixels of spacing between frame and dashboard

    Returns:
        Composite frame with video and dashboard side-by-side
    """
    frame_h, frame_w = frame.shape[:2]
    dash_h, dash_w = dashboard.shape[:2]

    # Match dashboard height to frame height
    if dash_h != frame_h:
        # Scale dashboard to match frame height while maintaining aspect ratio
        scale = frame_h / dash_h
        new_w = int(dash_w * scale)
        dashboard = cv2.resize(
            dashboard, (new_w, frame_h), interpolation=cv2.INTER_LINEAR
        )
        dash_h, dash_w = dashboard.shape[:2]

    # Create composite frame
    total_width = frame_w + dash_w + spacing
    composite = np.zeros((frame_h, total_width, 3), dtype=np.uint8)

    if position == "right":
        # Video on left, dashboard on right
        composite[:, :frame_w] = frame
        composite[:, frame_w + spacing :] = dashboard
    else:  # left
        # Dashboard on left, video on right
        composite[:, :dash_w] = dashboard
        composite[:, dash_w + spacing :] = frame

    return composite

options: show_source: true heading_level: 4

overlay_metrics_on_frame

Overlay metrics dashboard on video frame.

Parameters:

Name Type Description Default
frame ndarray

Input video frame (BGR)

required
dashboard ndarray

Dashboard image from create_metrics_dashboard

required
position str

Where to place dashboard ("right", "left", "bottom")

'right'
alpha float

Opacity of dashboard (0.0 = transparent, 1.0 = opaque)

0.9

Returns:

Type Description
ndarray

Frame with dashboard overlaid

Source code in src/climb_sensei/metrics_viz.py
def overlay_metrics_on_frame(
    frame: np.ndarray,
    dashboard: np.ndarray,
    position: str = "right",
    alpha: float = 0.9,
) -> np.ndarray:
    """Overlay metrics dashboard on video frame.

    Args:
        frame: Input video frame (BGR)
        dashboard: Dashboard image from create_metrics_dashboard
        position: Where to place dashboard ("right", "left", "bottom")
        alpha: Opacity of dashboard (0.0 = transparent, 1.0 = opaque)

    Returns:
        Frame with dashboard overlaid
    """
    frame_h, frame_w = frame.shape[:2]
    dash_h, dash_w = dashboard.shape[:2]

    # Create output frame
    output = frame.copy()

    if position == "right":
        # Place on right side
        x_offset = frame_w - dash_w - 10
        y_offset = 10
    elif position == "left":
        # Place on left side
        x_offset = 10
        y_offset = 10
    elif position == "bottom":
        # Place at bottom center
        x_offset = (frame_w - dash_w) // 2
        y_offset = frame_h - dash_h - 10
    else:
        x_offset = 10
        y_offset = 10

    # Ensure dashboard fits
    if x_offset + dash_w > frame_w:
        x_offset = frame_w - dash_w
    if y_offset + dash_h > frame_h:
        y_offset = frame_h - dash_h
    if x_offset < 0:
        x_offset = 0
    if y_offset < 0:
        y_offset = 0

    # Clip dashboard if needed
    dash_w = min(dash_w, frame_w - x_offset)
    dash_h = min(dash_h, frame_h - y_offset)
    dashboard = dashboard[:dash_h, :dash_w]

    # Blend dashboard onto frame
    roi = output[y_offset : y_offset + dash_h, x_offset : x_offset + dash_w]
    blended = cv2.addWeighted(dashboard, alpha, roi, 1 - alpha, 0)
    output[y_offset : y_offset + dash_h, x_offset : x_offset + dash_w] = blended

    return output

options: show_source: true heading_level: 4


Configuration

Configuration for pose visualization and analysis.

This module defines application-wide configuration including: - Pose landmarks and connections for climbing analysis - Model configuration (confidence thresholds, etc.) - Metrics calculation parameters - Visualization styling (colors, dimensions, etc.)

LandmarkIndex

Bases: IntEnum

MediaPipe Pose landmark indices.

Centralized definition of landmark indices for consistency across modules.

Source code in src/climb_sensei/config.py
class LandmarkIndex(IntEnum):
    """MediaPipe Pose landmark indices.

    Centralized definition of landmark indices for consistency across modules.
    """

    # Face/Head (0-10)
    NOSE = 0
    LEFT_EYE_INNER = 1
    LEFT_EYE = 2
    LEFT_EYE_OUTER = 3
    RIGHT_EYE_INNER = 4
    RIGHT_EYE = 5
    RIGHT_EYE_OUTER = 6
    LEFT_EAR = 7
    RIGHT_EAR = 8
    MOUTH_LEFT = 9
    MOUTH_RIGHT = 10

    # Upper Body
    LEFT_SHOULDER = 11
    RIGHT_SHOULDER = 12
    LEFT_ELBOW = 13
    RIGHT_ELBOW = 14
    LEFT_WRIST = 15
    RIGHT_WRIST = 16

    # Hands
    LEFT_PINKY = 17
    RIGHT_PINKY = 18
    LEFT_INDEX = 19
    RIGHT_INDEX = 20
    LEFT_THUMB = 21
    RIGHT_THUMB = 22

    # Lower Body
    LEFT_HIP = 23
    RIGHT_HIP = 24
    LEFT_KNEE = 25
    RIGHT_KNEE = 26
    LEFT_ANKLE = 27
    RIGHT_ANKLE = 28
    LEFT_HEEL = 29
    RIGHT_HEEL = 30
    LEFT_FOOT_INDEX = 31
    RIGHT_FOOT_INDEX = 32

MetricsConfig dataclass

Immutable metrics calculation configuration.

Attributes:

Name Type Description
lock_off_threshold_degrees float

Elbow angle threshold for lock-off detection

rest_velocity_threshold float

Max velocity for rest position detection

rest_body_angle_threshold float

Max body angle for rest position (degrees)

efficient_economy_ratio float

Threshold for efficient movement economy

fatigue_window_size int

Number of frames for fatigue analysis

com_body_weight float

Weight factor for center of mass calculation

Source code in src/climb_sensei/config.py
@dataclass(frozen=True)
class MetricsConfig:
    """Immutable metrics calculation configuration.

    Attributes:
        lock_off_threshold_degrees: Elbow angle threshold for lock-off detection
        rest_velocity_threshold: Max velocity for rest position detection
        rest_body_angle_threshold: Max body angle for rest position (degrees)
        efficient_economy_ratio: Threshold for efficient movement economy
        fatigue_window_size: Number of frames for fatigue analysis
        com_body_weight: Weight factor for center of mass calculation
    """

    lock_off_threshold_degrees: float = 90.0
    lock_off_velocity_threshold: float = 0.002
    rest_velocity_threshold: float = 0.01
    rest_body_angle_threshold: float = 15.0
    efficient_economy_ratio: float = 0.8
    fatigue_window_size: int = 90
    com_body_weight: float = 1.0

PoseConfig dataclass

Immutable pose detection and tracking configuration.

Attributes:

Name Type Description
min_detection_confidence float

Minimum confidence for pose detection (0.0-1.0)

min_tracking_confidence float

Minimum confidence for pose tracking (0.0-1.0)

timestamp_increment_ms int

Milliseconds per frame for temporal smoothing

Source code in src/climb_sensei/config.py
@dataclass(frozen=True)
class PoseConfig:
    """Immutable pose detection and tracking configuration.

    Attributes:
        min_detection_confidence: Minimum confidence for pose detection (0.0-1.0)
        min_tracking_confidence: Minimum confidence for pose tracking (0.0-1.0)
        timestamp_increment_ms: Milliseconds per frame for temporal smoothing
    """

    min_detection_confidence: float = 0.5
    min_tracking_confidence: float = 0.5
    timestamp_increment_ms: int = 33  # ~30fps

    def __post_init__(self) -> None:
        """Validate configuration values."""
        if not 0.0 <= self.min_detection_confidence <= 1.0:
            raise ValueError("min_detection_confidence must be between 0.0 and 1.0")
        if not 0.0 <= self.min_tracking_confidence <= 1.0:
            raise ValueError("min_tracking_confidence must be between 0.0 and 1.0")

__post_init__()

Validate configuration values.

Source code in src/climb_sensei/config.py
def __post_init__(self) -> None:
    """Validate configuration values."""
    if not 0.0 <= self.min_detection_confidence <= 1.0:
        raise ValueError("min_detection_confidence must be between 0.0 and 1.0")
    if not 0.0 <= self.min_tracking_confidence <= 1.0:
        raise ValueError("min_tracking_confidence must be between 0.0 and 1.0")

VisualizationConfig dataclass

Visualization styling configuration (immutable).

Source code in src/climb_sensei/config.py
@dataclass(frozen=True)
class VisualizationConfig:
    """Visualization styling configuration (immutable)."""

    # Drawing dimensions
    line_thickness: int = 3
    circle_radius: int = 7
    landmark_border_thickness: int = 2

    # Text styling
    font_scale: float = 3.0
    font_thickness: int = 2

    # Metrics overlay layout
    metrics_overlay_padding: int = 15
    metrics_line_height: int = 50
    metrics_overlay_bg_alpha: float = 0.7

    # Angle annotation
    angle_annotation_padding: int = 5

    # Plot settings for metrics dashboard
    plot_width: int = 500
    plot_height: int = 150
    plot_background_color: Tuple[int, int, int] = (40, 40, 40)
    plot_margin_left: int = 70
    plot_margin_right: int = 15
    plot_margin_top: int = 40
    plot_margin_bottom: int = 25

    # Plot text styling
    plot_title_font_scale: float = 0.8
    plot_title_thickness: int = 2
    plot_title_color: Tuple[int, int, int] = (200, 200, 200)
    plot_label_font_scale: float = 0.5
    plot_label_thickness: int = 2
    plot_label_color: Tuple[int, int, int] = (150, 150, 150)

    # Plot elements
    plot_grid_color: Tuple[int, int, int] = (60, 60, 60)
    plot_line_thickness: int = 2
    plot_current_marker_inner_color: Tuple[int, int, int] = (255, 255, 255)
    plot_current_marker_inner_radius: int = 4
    plot_current_marker_outer_radius: int = 6
    plot_current_marker_outer_thickness: int = 2
    plot_current_line_color: Tuple[int, int, int] = (100, 100, 100)

get_landmark_name(index)

Get the human-readable name for a landmark index.

Parameters:

Name Type Description Default
index int

Landmark index (0-32).

required

Returns:

Type Description
str

Human-readable landmark name.

Source code in src/climb_sensei/config.py
def get_landmark_name(index: int) -> str:
    """Get the human-readable name for a landmark index.

    Args:
        index: Landmark index (0-32).

    Returns:
        Human-readable landmark name.
    """
    try:
        return LandmarkIndex(index).name.lower()
    except ValueError:
        return f"unknown_{index}"

options: show_source: true heading_level: 3