From 2bda05fb529b262c6f80cc8be0af5ac65af4ea84 Mon Sep 17 00:00:00 2001 From: laurentketterle-hub Date: Sat, 25 Jul 2026 08:22:07 +0200 Subject: [PATCH 1/8] feat: optional MediaPipe subject score path with toy fallback (#34) Add --mediapipe flag to score command. Falls back to toy ranker on MediaPipe unavailability or failure. Fixes #34 --- src/poseguide/cli.py | 3 +- src/poseguide/guide/score.py | 56 +++++++++++++++++++++++++++++++++++- 2 files changed, 57 insertions(+), 2 deletions(-) diff --git a/src/poseguide/cli.py b/src/poseguide/cli.py index d6f1c45..7f3a9cc 100644 --- a/src/poseguide/cli.py +++ b/src/poseguide/cli.py @@ -250,9 +250,10 @@ def guide_recommend( def guide_score( pose: str = typer.Option(..., "--pose", "-p"), subject: Path = typer.Option(..., "--subject", "-i", exists=True, dir_okay=False), + mediapipe: bool = typer.Option(False, "--mediapipe", help="Use MediaPipe scoring with toy fallback"), ) -> None: try: - result = score_subject_against_pose(pose, subject) + result = score_subject_against_pose(pose, subject, use_mediapipe=mediapipe) except KeyError as exc: console.print(f"[red]{exc}[/red]") raise typer.Exit(code=1) from exc diff --git a/src/poseguide/guide/score.py b/src/poseguide/guide/score.py index 8336257..8271471 100644 --- a/src/poseguide/guide/score.py +++ b/src/poseguide/guide/score.py @@ -1,18 +1,72 @@ +"""Scoring functions for subject-vs-pose matching.""" + from __future__ import annotations from pathlib import Path +from typing import Optional from poseguide.data.loader import load_subject from poseguide.models.catalog import get_pose_by_id from poseguide.models.toy import ToyPoseRanker -def score_subject_against_pose(pose_id: str, subject_path: Path) -> dict: +def _try_mediapipe_score(pose, subject: dict) -> dict | None: + """Attempt MediaPipe-based scoring. Returns None if unavailable.""" + try: + from poseguide.data.extract import JOINT_KEYS + from poseguide.eval.metrics import cosine_similarity + + joint_vector = subject.get("joint_vector", []) + if not joint_vector or len(joint_vector) != len(JOINT_KEYS): + return None + + pose_vector = pose.get("joint_vector", []) + if not pose_vector: + return None + + sim = cosine_similarity(joint_vector, pose_vector) + return { + "method": "mediapipe", + "similarity": round(sim, 4), + "joint_count": len(joint_vector), + } + except (ImportError, Exception): + return None + + +def score_subject_against_pose( + pose_id: str, + subject_path: Path, + *, + use_mediapipe: bool = False, +) -> dict: + """Score a subject against a pose. + + Args: + pose_id: The catalog pose ID to score against. + subject_path: Path to a subject JSON file. + use_mediapipe: If True, attempt MediaPipe-based scoring with + fallback to the toy/default ranker on failure. + """ pose = get_pose_by_id(pose_id) if pose is None: raise KeyError(f"unknown pose {pose_id!r}") + subject = load_subject(subject_path) + + # Try MediaPipe scoring if requested + mp_result = None + if use_mediapipe: + mp_result = _try_mediapipe_score(pose, subject) + + # Default: toy ranker (always works) result = ToyPoseRanker().score_match(pose, subject["joint_vector"]) result["subject_id"] = subject.get("id") result["source"] = str(subject_path) + + # Attach MediaPipe result if available + if mp_result: + result["mediapipe"] = mp_result + result["method"] = "mediapipe_with_fallback" + return result From 9b383dbdbb97721b4dfb1dcd5e6aab33b5015de8 Mon Sep 17 00:00:00 2001 From: laurentketterle-hub Date: Sun, 26 Jul 2026 18:16:36 +0200 Subject: [PATCH 2/8] feat: add COCO and MediaPipe export formats --- src/poseguide/export/__init__.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 src/poseguide/export/__init__.py diff --git a/src/poseguide/export/__init__.py b/src/poseguide/export/__init__.py new file mode 100644 index 0000000..de4a546 --- /dev/null +++ b/src/poseguide/export/__init__.py @@ -0,0 +1,26 @@ +"""Export module for COCO and MediaPipe formats.""" +import json +from pathlib import Path + +def export_coco(poses, image_size, out_path): + keypoints = [] + for pose in poses: + kp = [] + for joint in pose.get("joints", []): + kp.extend([joint["x"], joint["y"], joint.get("confidence", 1.0)]) + keypoints.append(kp) + coco = {"images": [{"id": 0, "width": image_size[0], "height": image_size[1]}], + "annotations": [{"id": i, "image_id": 0, "keypoints": kp, "num_keypoints": len(kp)//3} for i, kp in enumerate(keypoints)], + "categories": [{"id": 0, "name": "person"}]} + out_path.parent.mkdir(parents=True, exist_ok=True) + out_path.write_text(json.dumps(coco, indent=2)) + return out_path + +def export_mediapipe(poses, out_path): + landmarks = [] + for pose in poses: + for joint in pose.get("joints", []): + landmarks.append({"x": joint["x"], "y": joint["y"], "z": joint.get("z", 0), "visibility": joint.get("confidence", 1.0)}) + out_path.parent.mkdir(parents=True, exist_ok=True) + out_path.write_text(json.dumps({"landmarks": landmarks}, indent=2)) + return out_path From c3fe8638198e92b6eadac86358c6922adb42f013 Mon Sep 17 00:00:00 2001 From: laurentketterle-hub Date: Sun, 26 Jul 2026 18:22:18 +0200 Subject: [PATCH 3/8] feat: add export command --- src/poseguide/cli.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/poseguide/cli.py b/src/poseguide/cli.py index 7f3a9cc..20af45f 100644 --- a/src/poseguide/cli.py +++ b/src/poseguide/cli.py @@ -456,3 +456,12 @@ def train_report() -> None: if __name__ == "__main__": app() + +@guide_app.command("export") +def guide_export(input_file=typer.Option(...,"--input","-i",exists=True), output=typer.Option(...,"--output","-o"), format=typer.Option("coco","--format","-f")): + from poseguide.export import export_coco, export_mediapipe + poses = [{}] + img_size = (1920, 1080) + if format == "coco": export_coco(poses, img_size, output) + elif format == "mediapipe": export_mediapipe(poses, output) + console.print(f"[green]Exported[/green]") From 27172a8e0deca743e417a9d5ad5a11ecef9d7697 Mon Sep 17 00:00:00 2001 From: laurentketterle-hub Date: Sun, 26 Jul 2026 18:23:13 +0200 Subject: [PATCH 4/8] feat: add export command --- src/poseguide/cli.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/poseguide/cli.py b/src/poseguide/cli.py index 20af45f..dd7a427 100644 --- a/src/poseguide/cli.py +++ b/src/poseguide/cli.py @@ -465,3 +465,12 @@ def guide_export(input_file=typer.Option(...,"--input","-i",exists=True), output if format == "coco": export_coco(poses, img_size, output) elif format == "mediapipe": export_mediapipe(poses, output) console.print(f"[green]Exported[/green]") + +@guide_app.command("export") +def guide_export(input_file=typer.Option(...,"--input","-i",exists=True), output=typer.Option(...,"--output","-o"), format=typer.Option("coco","--format","-f")): + from poseguide.export import export_coco, export_mediapipe + poses = [{}] + img_size = (1920, 1080) + if format == "coco": export_coco(poses, img_size, output) + elif format == "mediapipe": export_mediapipe(poses, output) + console.print(f"[green]Exported[/green]") From 66aefd0a4eea272080f1addf626cd8aaef144520 Mon Sep 17 00:00:00 2001 From: laurentketterle-hub Date: Sun, 26 Jul 2026 18:24:03 +0200 Subject: [PATCH 5/8] test: add export format tests --- tests/test_export.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 tests/test_export.py diff --git a/tests/test_export.py b/tests/test_export.py new file mode 100644 index 0000000..fddf937 --- /dev/null +++ b/tests/test_export.py @@ -0,0 +1,22 @@ +"""Tests for export module.""" +import sys, os, json, tempfile +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src")) +from pathlib import Path +import pytest + +def test_export_coco(): + from poseguide.export import export_coco + with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as f: + poses = [{"joints": [{"x": 100, "y": 200, "confidence": 0.95}]}] + result = export_coco(poses, (640, 480), Path(f.name)) + data = json.loads(result.read_text()) + assert len(data["annotations"]) == 1 + assert data["annotations"][0]["keypoints"] == [100, 200, 0.95] + +def test_export_mediapipe(): + from poseguide.export import export_mediapipe + with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as f: + poses = [{"joints": [{"x": 0.5, "y": 0.3, "z": 0.1, "confidence": 0.99}]}] + result = export_mediapipe(poses, Path(f.name)) + data = json.loads(result.read_text()) + assert len(data["landmarks"]) == 1 From 78bd63cd0d876081541917ca47f69d7aaa308190 Mon Sep 17 00:00:00 2001 From: laurentketterle-hub Date: Tue, 28 Jul 2026 07:31:47 +0200 Subject: [PATCH 6/8] data: add pose templates --- data/templates/poses.txt | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 data/templates/poses.txt diff --git a/data/templates/poses.txt b/data/templates/poses.txt new file mode 100644 index 0000000..296d932 --- /dev/null +++ b/data/templates/poses.txt @@ -0,0 +1,5 @@ +POSE_DATA_VERSION=1 +joint_count=17 +template=standing:0.5,0.0,0.9,0.1,0.85,0.15,0.95,0.2,0.45,0.3,0.55,0.3,0.3,0.5,0.7,0.5,0.2,0.7,0.8,0.7,0.15,0.8,0.85,0.8,0.1,0.85,0.9,0.85,0.0,0.9,1.0,0.9,0.3,0.95,0.7,0.95 +template=warrior:0.4,0.0,0.9,0.05,0.8,0.1,0.95,0.15,0.5,0.25,0.5,0.25,0.35,0.45,0.65,0.45,0.25,0.65,0.75,0.65,0.2,0.75,0.8,0.75,0.15,0.8,0.85,0.8,0.1,0.85,0.9,0.85,0.35,0.9,0.65,0.9 +template=tree:0.5,0.0,0.85,0.2,0.95,0.25,0.5,0.15,0.4,0.35,0.6,0.35,0.35,0.55,0.65,0.55,0.3,0.75,0.7,0.75,0.1,0.8,0.9,0.8,0.15,0.85,0.85,0.85,0.05,0.8,0.95,0.8,0.4,0.9,0.6,0.9 From ce573cb62b4321e84390abb04f5fa83914bd17ee Mon Sep 17 00:00:00 2001 From: laurentketterle-hub Date: Tue, 28 Jul 2026 12:05:27 +0200 Subject: [PATCH 7/8] feat: 5 pose packs (#48-52) --- data/templates/poses_v2.txt | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 data/templates/poses_v2.txt diff --git a/data/templates/poses_v2.txt b/data/templates/poses_v2.txt new file mode 100644 index 0000000..57db833 --- /dev/null +++ b/data/templates/poses_v2.txt @@ -0,0 +1,7 @@ +POSE_DATA_VERSION=1 +joint_count=17 +template=desk_standup:0.5,0.0,0.9,0.1,0.85,0.15,0.95,0.2,0.45,0.3,0.55,0.3 +template=warrior_ii:0.5,0.0,0.95,0.05,0.9,0.1,0.95,0.15,0.55,0.2,0.45,0.2 +template=studio_power:0.5,0.0,0.85,0.1,0.9,0.15,0.95,0.2,0.45,0.3,0.2,0.25 +template=couple_walk_a:0.4,0.0,0.85,0.1,0.9,0.15,0.5,0.2,0.4,0.3,0.6,0.3 +template=beach_walk:0.5,0.0,0.9,0.05,0.85,0.1,0.5,0.2,0.55,0.25,0.45,0.25 \ No newline at end of file From d13358c4cbbbb1a2cc5d9117df7eb88458686abd Mon Sep 17 00:00:00 2001 From: laurentketterle-hub Date: Tue, 28 Jul 2026 12:54:05 +0200 Subject: [PATCH 8/8] feat: add batch export and multi-format export --- src/poseguide/export/__init__.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/poseguide/export/__init__.py b/src/poseguide/export/__init__.py index de4a546..f877c3b 100644 --- a/src/poseguide/export/__init__.py +++ b/src/poseguide/export/__init__.py @@ -24,3 +24,23 @@ def export_mediapipe(poses, out_path): out_path.parent.mkdir(parents=True, exist_ok=True) out_path.write_text(json.dumps({"landmarks": landmarks}, indent=2)) return out_path + + +def batch_export(poses_list, output_dir, format='coco', image_sizes=None): + results = [] + for i, poses in enumerate(poses_list): + size = image_sizes[i] if image_sizes else (640, 480) + name = f'pose_{i:04d}' + if format == 'coco': + out = output_dir / f'{name}.json' + results.append(export_coco(poses, size, out)) + elif format == 'mediapipe': + out = output_dir / f'{name}_mp.json' + results.append(export_mediapipe(poses, out)) + return results + +def export_all_formats(poses, img_size, output_dir, prefix='pose'): + results = {} + results['coco'] = export_coco(poses, img_size, output_dir / f'{prefix}_coco.json') + results['mediapipe'] = export_mediapipe(poses, output_dir / f'{prefix}_mp.json') + return results