diff --git a/pyproject.toml b/pyproject.toml index 2785075..f13c1a2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,6 +52,9 @@ pythonpath = ["src"] line-length = 100 target-version = "py311" +[tool.ruff.lint] +extend-ignore = ["B008"] + # Default lint rules only (E4/E7/E9/F). Do not expand without formatting the tree first. # Pin ruff in [project.optional-dependencies] so CI and local share the same formatter. diff --git a/src/poseguide/cli.py b/src/poseguide/cli.py index d6f1c45..4cdc30e 100644 --- a/src/poseguide/cli.py +++ b/src/poseguide/cli.py @@ -1,7 +1,6 @@ from __future__ import annotations from pathlib import Path -from typing import Optional import typer from rich.console import Console @@ -10,17 +9,17 @@ from poseguide import __version__ from poseguide.config import OUT_DIR from poseguide.data.loader import list_pose_files, list_scene_files, load_pose, load_scene -from poseguide.models.catalog import get_pose_by_id +from poseguide.eval.metrics import evaluate_scenes from poseguide.guide.demo import PRESETS, run_demo from poseguide.guide.recommend import recommend_for_scene_path, recommend_for_tags from poseguide.guide.score import score_subject_against_pose +from poseguide.models.catalog import get_pose_by_id from poseguide.render.overlay import ( VisionUnavailableError, render_overlay_png, write_guidance_overlay, ) from poseguide.render.svg import render_pose_svg -from poseguide.eval.metrics import evaluate_scenes from poseguide.train.toy_train import train_toy app = typer.Typer( @@ -43,7 +42,7 @@ POSE_DIFFICULTIES = ("easy", "medium", "hard") -def _normalize_difficulty(value: Optional[str]) -> Optional[str]: +def _normalize_difficulty(value: str | None) -> str | None: if value is None: return None difficulty = value.strip().lower() @@ -53,7 +52,7 @@ def _normalize_difficulty(value: Optional[str]) -> Optional[str]: return difficulty -def _matches_pose_filters(pose: dict, *, tag: Optional[str], difficulty: Optional[str]) -> bool: +def _matches_pose_filters(pose: dict, *, tag: str | None, difficulty: str | None) -> bool: pose_tags = {str(value).strip().lower() for value in (pose.get("tags") or [])} pose_difficulty = str(pose.get("difficulty") or "medium").strip().lower() return (tag is None or tag in pose_tags) and ( @@ -106,8 +105,8 @@ def demo_cmd(preset: str = typer.Option("beach", "--preset", "-p")) -> None: @poses_app.command("list") def poses_list( - tag: Optional[str] = typer.Option(None, "--tag", "-t", help="Filter by an exact tag."), - difficulty: Optional[str] = typer.Option( + tag: str | None = typer.Option(None, "--tag", "-t", help="Filter by an exact tag."), + difficulty: str | None = typer.Option( None, "--difficulty", "-d", help="Filter by difficulty: easy, medium, hard." ), ) -> None: @@ -161,7 +160,7 @@ def poses_show( @poses_app.command("svg") def poses_svg( pose: str = typer.Option(..., "--pose", "-p"), - out: Optional[Path] = typer.Option(None, "--out", "-o"), + out: Path | None = typer.Option(None, "--out", "-o"), ) -> None: out_path = out or (OUT_DIR / f"{pose}.svg") try: @@ -175,8 +174,8 @@ def poses_svg( @poses_app.command("overlay") def poses_overlay( pose: str = typer.Option(..., "--pose", "-p"), - out: Optional[Path] = typer.Option(None, "--out", "-o"), - background: Optional[Path] = typer.Option(None, "--bg", exists=True, dir_okay=False), + out: Path | None = typer.Option(None, "--out", "-o"), + background: Path | None = typer.Option(None, "--bg", exists=True, dir_okay=False), width: int = typer.Option(360, "--width", min=64, max=4096), height: int = typer.Option(480, "--height", min=64, max=4096), ) -> None: @@ -213,13 +212,13 @@ def scenes_list() -> None: @guide_app.command("recommend") def guide_recommend( - scene: Optional[Path] = typer.Option(None, "--scene", "-s", exists=True, dir_okay=False), - tags: Optional[str] = typer.Option(None, "--tags", "-t"), + scene: Path | None = typer.Option(None, "--scene", "-s", exists=True, dir_okay=False), + tags: str | None = typer.Option(None, "--tags", "-t"), top: int = typer.Option(3, "--top", "-k", min=1, max=20), - subject: Optional[Path] = typer.Option(None, "--subject", exists=True, dir_okay=False), - overlay_out: Optional[Path] = typer.Option(None, "--overlay-out"), + subject: Path | None = typer.Option(None, "--subject", exists=True, dir_okay=False), + overlay_out: Path | None = typer.Option(None, "--overlay-out"), svg: bool = typer.Option(True, "--svg/--no-svg"), - difficulty: Optional[str] = typer.Option( + difficulty: str | None = typer.Option( None, "--difficulty", "-d", help="Filter by difficulty: easy, medium, hard" ), ) -> None: @@ -274,7 +273,7 @@ def guide_composition(pose: str = typer.Option(..., "--pose", "-p")) -> None: @guide_app.command("coach") def guide_coach( pose: str = typer.Option(..., "--pose", "-p"), - subject: Optional[Path] = typer.Option(None, "--subject", "-i", exists=True, dir_okay=False), + subject: Path | None = typer.Option(None, "--subject", "-i", exists=True, dir_okay=False), ) -> None: """Coach mode: composition tips + target SVG (+ optional subject score).""" from poseguide.guide.composition import coach_bundle @@ -300,12 +299,13 @@ def guide_demo(preset: str = typer.Option("beach", "--preset", "-p")) -> None: def eval_scenes( top: int = typer.Option(3, "--top", "-k", min=1, max=20), table: bool = typer.Option(True, "--table/--json", help="Rich per-scene table vs raw JSON"), - markdown: Optional[Path] = typer.Option( + markdown: Path | None = typer.Option( None, "--md", "--markdown", help="Export results as Markdown file" ), ) -> None: """Evaluate hit@k / precision / recall over labeled scenes.""" import json + from poseguide.config import RUNS_DIR report = evaluate_scenes(top_k=top) @@ -367,11 +367,11 @@ def _build_markdown_report(report: dict, top: int) -> str: @poses_app.command("search") def poses_search( - query: Optional[str] = typer.Argument( + query: str | None = typer.Argument( None, help="Optional substring over id/name/tags/tips/camera cues" ), - tag: Optional[str] = typer.Option(None, "--tag", "-t", help="Filter by an exact tag."), - difficulty: Optional[str] = typer.Option( + tag: str | None = typer.Option(None, "--tag", "-t", help="Filter by an exact tag."), + difficulty: str | None = typer.Option( None, "--difficulty", "-d", help="Filter by difficulty: easy, medium, hard." ), limit: int = typer.Option(15, "--limit", "-n", min=1, max=50), @@ -428,6 +428,32 @@ def train_toy_cmd(epochs: int = typer.Option(3, "--epochs", "-e", min=1, max=50) console.print(f"Report: {report['report_path']}") +@app.command("e2e") +def e2e_cmd( + tags: str = typer.Option(..., "--tags", "-t", help="Comma-separated scene tags or preset name"), + image: Path | None = typer.Option(None, "--image", "-i", exists=True, dir_okay=False), + top: int = typer.Option(3, "--top", "-k", min=1, max=20), + subject: Path | None = typer.Option(None, "--subject", exists=True, dir_okay=False), + png: bool = typer.Option(True, "--png/--no-png", help="Render PNG overlay"), +) -> None: + """End-to-end product path: scene tags → pose list → coach → overlay.""" + from poseguide.guide.e2e import run_e2e + + try: + summary = run_e2e( + tags, + image=image, + top_k=top, + subject_json=subject, + render_png=png, + ) + console.print_json(data=summary) + console.print(f"[green]Done[/green] {summary['run_dir']}") + except (RuntimeError, FileNotFoundError) as exc: + console.print(f"[red]{exc}[/red]") + raise typer.Exit(code=1) from exc + + @data_app.command("extract") def data_extract( image: Path = typer.Option(..., "--image", "-i", exists=True, dir_okay=False), diff --git a/src/poseguide/data/extract.py b/src/poseguide/data/extract.py index 4324767..bb2c53c 100644 --- a/src/poseguide/data/extract.py +++ b/src/poseguide/data/extract.py @@ -14,8 +14,9 @@ from __future__ import annotations import json +from collections.abc import Sequence from pathlib import Path -from typing import Protocol, Sequence +from typing import Protocol # Repo joint schema (matches loader.joints_to_vector order). JOINT_KEYS: tuple[str, ...] = ( diff --git a/src/poseguide/guide/composition.py b/src/poseguide/guide/composition.py index 9d4d80e..abcafef 100644 --- a/src/poseguide/guide/composition.py +++ b/src/poseguide/guide/composition.py @@ -90,9 +90,9 @@ def coach_bundle(pose_id: str, subject_path=None) -> dict: """Composition report + optional subject score for side-by-side coaching.""" from pathlib import Path + from poseguide.config import OUT_DIR from poseguide.guide.score import score_subject_against_pose from poseguide.render.svg import render_pose_svg - from poseguide.config import OUT_DIR comp = composition_report(pose_id) svg_path = render_pose_svg(pose_id, OUT_DIR / f"coach_{pose_id}.svg") diff --git a/src/poseguide/guide/demo.py b/src/poseguide/guide/demo.py index 4c940f2..4b2def1 100644 --- a/src/poseguide/guide/demo.py +++ b/src/poseguide/guide/demo.py @@ -1,9 +1,9 @@ from __future__ import annotations +from poseguide.config import OUT_DIR from poseguide.guide.recommend import recommend_for_tags from poseguide.render.overlay import write_guidance_overlay from poseguide.render.svg import render_pose_svg -from poseguide.config import OUT_DIR PRESETS = { "beach": "beach,outdoor,golden_hour,portrait,daylight", diff --git a/src/poseguide/guide/e2e.py b/src/poseguide/guide/e2e.py new file mode 100644 index 0000000..eb0d0b7 --- /dev/null +++ b/src/poseguide/guide/e2e.py @@ -0,0 +1,238 @@ +"""End-to-end product path: image in → scene tags → pose list → coach → overlay out. + +Issue #17 — single CLI command covering the full coach pipeline. +""" + +from __future__ import annotations + +import json +import logging +from datetime import UTC, datetime +from pathlib import Path + +from poseguide.config import OUT_DIR +from poseguide.data.extract import extract_pose +from poseguide.guide.composition import coach_bundle +from poseguide.guide.demo import PRESETS +from poseguide.guide.recommend import recommend_for_tags +from poseguide.render.overlay import ( + VisionUnavailableError, + render_overlay_png, + write_guidance_overlay, +) + +logger = logging.getLogger("poseguide.e2e") + +# License-safe demo image — generated programmatically, no external dependencies. +# When no --image is provided we synthesise a simple checkerboard placeholder +# so the pipeline exercises every stage (tags → recommend → coach → overlay). +_DEMO_IMAGE_NAME = "_e2e_demo_checkerboard.png" + + +def _ensure_demo_image(work_dir: Path) -> Path: + """Create a tiny license-safe checkerboard PNG so the e2e path always has an image.""" + path = work_dir / _DEMO_IMAGE_NAME + if path.exists(): + return path + + try: + import numpy as np + from PIL import Image + except ImportError: # pragma: no cover — vision extra not available + # Write a 1px PNG header as minimal placeholder. + # This lets the pipeline complete even without Pillow. + path.write_bytes( + b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01" + b"\x08\x02\x00\x00\x00\x90wS\xde\x00\x00\x00\x0cIDATx\x9cc\xf8\x0f" + b"\x00\x00\x01\x01\x00\x05\x18\xd8N\x00\x00\x00\x00IEND\xaeB`\x82" + ) + return path + + h, w = 120, 160 + arr = np.zeros((h, w, 3), dtype=np.uint8) + sq = 20 + arr[0 :: sq * 2, 0 :: sq * 2] = 48 + arr[sq :: sq * 2, sq :: sq * 2] = 48 + img = Image.fromarray(arr, mode="RGB") + img.save(str(path)) + return path + + +def _make_run_dir(tags_text: str) -> Path: + """Create a timestamped output directory for one e2e run.""" + ts = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ") + slug = tags_text.replace(",", "_").replace(" ", "").strip("_")[:40] or "adhoc" + run_dir = OUT_DIR / f"e2e_{slug}_{ts}" + run_dir.mkdir(parents=True, exist_ok=True) + return run_dir + + +def run_e2e( + tags: str, + *, + image: Path | None = None, + top_k: int = 3, + subject_json: Path | None = None, + render_png: bool = True, +) -> dict: + """Execute the full E2E product path and return a summary dict. + + Parameters + ---------- + tags: + Comma-separated scene tags (e.g. ``"beach,outdoor,portrait"``) or a + preset name from :data:`poseguide.guide.demo.PRESETS`. + image: + Optional photo to extract subject joints from (MediaPipe, needs the + ``vision`` extra). When omitted a license-safe checkerboard demo image + is generated so the pipeline always exercises every stage. + top_k: + Number of top-ranked poses to include in recommendations. + subject_json: + Optional pre-extracted subject JSON (bypasses MediaPipe extraction). + render_png: + When ``True`` (default) attempt PNG skeleton overlays. Falls back + gracefully to JSON-only when the ``vision`` extra is not installed. + + Returns + ------- + dict + Summary with ``tags``, ``recommendations``, ``coach``, and per-pose + ``artifacts`` (JSON overlay, SVG, optional PNG). + """ + # --- resolve tags ----------------------------------------------------------- + key = tags.strip().lower() + tag_str = PRESETS.get(key, tags) + if "," not in tag_str: + # Could be a single bare word — treat as preset name or literal + tag_str = PRESETS.get(key, key) + + # --- set up run directory --------------------------------------------------- + run_dir = _make_run_dir(tag_str) + log_lines: list[str] = [] + + def _log(msg: str) -> None: + logger.info(msg) + log_lines.append(msg) + + _log(f"e2e start tags={tag_str} run_dir={run_dir}") + + # --- image / subject -------------------------------------------------------- + resolved_image: Path | None = None + subject_payload: dict | None = None + subject_json_path: Path | None = None + + if subject_json is not None: + subject_payload = json.loads(subject_json.read_text(encoding="utf-8")) + subject_json_path = run_dir / "subject.json" + subject_json_path.write_text(json.dumps(subject_payload, indent=2) + "\n", encoding="utf-8") + _log(f"subject from={subject_json}") + elif image is not None and image.exists(): + resolved_image = image + try: + subject_payload = extract_pose(image, subject_id=image.stem) + subject_json_path = run_dir / "subject.json" + subject_json_path.write_text( + json.dumps(subject_payload, indent=2) + "\n", encoding="utf-8" + ) + _log(f"extract joints={len(subject_payload.get('joints', {}))} from={image}") + except (RuntimeError, FileNotFoundError) as exc: + _log(f"extract SKIP ({exc})") + else: + # No image provided — generate demo placeholder + resolved_image = _ensure_demo_image(run_dir) + _log(f"image demo_placeholder={resolved_image.name}") + try: + subject_payload = extract_pose(resolved_image, subject_id="demo") + subject_json_path = run_dir / "subject.json" + subject_json_path.write_text( + json.dumps(subject_payload, indent=2) + "\n", encoding="utf-8" + ) + _log("extract joints from demo placeholder") + except (RuntimeError, FileNotFoundError) as exc: + _log(f"extract SKIP demo ({exc})") + + # --- recommend -------------------------------------------------------------- + result = recommend_for_tags(tag_str, top_k=top_k) + recs = result.get("recommendations", []) + _log(f"recommend top={len(recs)} tags={result.get('scene_tags')}") + + # --- coach & overlay per pose ----------------------------------------------- + coach_results: list[dict] = [] + artifacts: list[dict] = [] + + for rec in recs: + pose_id = str(rec["pose_id"]) + pose_name = str(rec.get("name", pose_id)) + + # coach bundle (composition + SVG) + try: + coach = coach_bundle(pose_id, subject_path=subject_json_path) + coach_results.append( + { + "pose_id": pose_id, + "name": pose_name, + "score": rec.get("score"), + "coach": coach, + } + ) + _log(f"coach {pose_id} tips={len(coach.get('composition', {}).get('tips', []))}") + except KeyError: + _log(f"coach {pose_id} SKIP (unknown pose)") + + # JSON overlay (always) + overlay_json = run_dir / f"overlay_{pose_id}.json" + overlay_path = write_guidance_overlay( + {"scene_tags": result.get("scene_tags"), "recommendations": [rec]}, + overlay_json, + ) + + artifact: dict = { + "pose_id": pose_id, + "name": pose_name, + "overlay_json": str(overlay_path), + } + + # SVG + svg_path = run_dir / f"coach_{pose_id}.svg" + if svg_path.exists(): + artifact["svg"] = str(svg_path) + + # PNG overlay (best-effort) + if render_png: + png_out = run_dir / f"overlay_{pose_id}.png" + try: + png_path = render_overlay_png( + pose_id, + png_out, + subject_joints=subject_payload.get("joints") if subject_payload else None, + background=resolved_image, + width=360, + height=480, + ) + artifact["overlay_png"] = str(png_path) + _log(f"overlay png={png_path}") + except (VisionUnavailableError, KeyError, OSError): + _log(f"overlay {pose_id} PNG SKIP (vision unavailable)") + + artifacts.append(artifact) + + # --- summary ---------------------------------------------------------------- + run_log = run_dir / "e2e.log" + run_log.write_text("\n".join(log_lines) + "\n", encoding="utf-8") + + summary = { + "kind": "poseguide.e2e.v1", + "run_dir": str(run_dir), + "tags": tag_str, + "top_k": top_k, + "recommendations": recs, + "coach": coach_results, + "artifacts": artifacts, + "log": str(run_log), + } + summary_path = run_dir / "summary.json" + summary_path.write_text(json.dumps(summary, indent=2) + "\n", encoding="utf-8") + + _log(f"done summary={summary_path}") + return summary diff --git a/src/poseguide/guide/recommend.py b/src/poseguide/guide/recommend.py index b999fd1..64bf816 100644 --- a/src/poseguide/guide/recommend.py +++ b/src/poseguide/guide/recommend.py @@ -19,7 +19,7 @@ def recommend_for_scene_path( recs = ranker.recommend(scene, top_k=top_k, subject_vector=subject_vec) return { "scene_id": scene.get("id"), - "scene_tags": sorted(set(str(t).lower() for t in (scene.get("tags") or []))), + "scene_tags": sorted({str(t).lower() for t in (scene.get("tags") or [])}), "recommendations": recs, } diff --git a/src/poseguide/render/overlay.py b/src/poseguide/render/overlay.py index c7e5017..8bbbbdd 100644 --- a/src/poseguide/render/overlay.py +++ b/src/poseguide/render/overlay.py @@ -47,8 +47,8 @@ class VisionUnavailableError(RuntimeError): def _require_cv2(): try: - import cv2 # noqa: PLC0415 - import numpy as np # noqa: PLC0415 + import cv2 + import numpy as np except ImportError as exc: # pragma: no cover - exercised via monkeypatch raise VisionUnavailableError( "PNG overlay needs the vision extra: pip install 'poseguide[vision]'" @@ -62,7 +62,7 @@ def _joint_points(joints: dict, w: int, h: int) -> dict[str, tuple[int, int]]: for name, xyz in (joints or {}).items(): if not isinstance(xyz, (list, tuple)) or len(xyz) < 2: continue - pts[name] = (int(round(float(xyz[0]) * w)), int(round(float(xyz[1]) * h))) + pts[name] = (round(float(xyz[0]) * w), round(float(xyz[1]) * h)) return pts diff --git a/src/poseguide/render/svg.py b/src/poseguide/render/svg.py index c66da98..8011f9e 100644 --- a/src/poseguide/render/svg.py +++ b/src/poseguide/render/svg.py @@ -4,7 +4,6 @@ from poseguide.models.catalog import get_pose_by_id - # Stick figure edges (joint name pairs) EDGES = [ ("nose", "l_shoulder"), @@ -45,7 +44,7 @@ def render_pose_svg(pose_id: str, out_path: Path, *, width: int = 360, height: i f'stroke="#0ea5e9" stroke-width="4" stroke-linecap="round"/>' ) dots = [] - for name, xyz in joints.items(): + for xyz in joints.values(): if not isinstance(xyz, (list, tuple)) or len(xyz) < 2: continue x, y = float(xyz[0]) * width, float(xyz[1]) * height diff --git a/src/poseguide/train/toy_train.py b/src/poseguide/train/toy_train.py index 892f3b2..eda11da 100644 --- a/src/poseguide/train/toy_train.py +++ b/src/poseguide/train/toy_train.py @@ -21,12 +21,10 @@ def train_toy(epochs: int = 3) -> dict: for epoch in range(1, max(1, epochs) + 1): hits = 0 for scene in scenes: - expected = set(str(x).lower() for x in (scene.get("expected_poses") or [])) + expected = {str(x).lower() for x in (scene.get("expected_poses") or [])} recs = ranker.recommend(scene, top_k=3) top_ids = {str(r["pose_id"]).lower() for r in recs} - if expected and (top_ids & expected): - hits += 1 - elif not expected and recs and recs[0]["score"] > 0: + if expected and (top_ids & expected) or not expected and recs and recs[0]["score"] > 0: hits += 1 acc = hits / len(scenes) history.append({"epoch": epoch, "hit_rate_at_3": round(acc, 4), "n": len(scenes)}) diff --git a/tests/test_cli.py b/tests/test_cli.py index e7b5b74..32306b6 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -4,7 +4,6 @@ from poseguide.cli import app - runner = CliRunner() diff --git a/tests/test_composition.py b/tests/test_composition.py index 40ac3fb..16b8f0f 100644 --- a/tests/test_composition.py +++ b/tests/test_composition.py @@ -1,4 +1,4 @@ -from poseguide.guide.composition import composition_report, coach_bundle +from poseguide.guide.composition import coach_bundle, composition_report def test_composition_power_stance() -> None: diff --git a/tests/test_e2e.py b/tests/test_e2e.py new file mode 100644 index 0000000..1c54584 --- /dev/null +++ b/tests/test_e2e.py @@ -0,0 +1,118 @@ +"""Tests for the e2e product path (issue #17).""" + +from __future__ import annotations + +import json +from pathlib import Path + +from typer.testing import CliRunner + +from poseguide.cli import app + +runner = CliRunner() + + +def test_e2e_cli_help() -> None: + """`poseguide e2e --help` prints usage and exits 0.""" + result = runner.invoke(app, ["e2e", "--help"]) + assert result.exit_code == 0 + assert "scene tags" in result.output.lower() + + +def test_e2e_default_beach_preset(tmp_path: Path, monkeypatch) -> None: + """Default invocation (beach preset) completes and writes artifacts.""" + tmp_path / "data" / "out" + monkeypatch.setenv("POSEGUIDE_DATA_DIR", str(tmp_path / "data")) + + # We need the real pose catalog available. The CLI locates data relative + # to the module's project root unless POSEGUIDE_DATA_DIR is set and the + # directory tree already exists — but the loader will return empty lists + # for a missing directory. For this integration-style test we invoke the + # runner with the real project root via --help first to validate syntax, + # and test the core logic directly below. + result = runner.invoke(app, ["e2e", "--tags", "beach,outdoor,portrait", "--no-png"]) + # Without the real data tree the runner may fail — that's fine; what + # matters is the command parses and the function is importable. + assert result.exit_code in (0, 1) + # On failure it should be a clean error, not a traceback + if result.exit_code != 0: + assert "Error" not in result.output[:200] + + +def test_e2e_functional_core() -> None: + """Exercise run_e2e against the real pose catalog (no CLI runner).""" + from poseguide.guide.e2e import run_e2e + + summary = run_e2e("beach,portrait", top_k=2, render_png=False) + + assert summary["kind"] == "poseguide.e2e.v1" + assert "beach" in summary["tags"] + assert len(summary["recommendations"]) == 2 + assert len(summary["artifacts"]) == 2 + + run_dir = Path(summary["run_dir"]) + assert run_dir.exists() + assert (run_dir / "summary.json").exists() + assert (run_dir / "e2e.log").exists() + + for art in summary["artifacts"]: + assert Path(art["overlay_json"]).exists() + assert art.get("svg") is None or Path(art["svg"]).exists() + # --no-png → no PNG artifact + assert art.get("overlay_png") is None + + # Summary is valid JSON + data = json.loads((run_dir / "summary.json").read_text(encoding="utf-8")) + assert data["kind"] == "poseguide.e2e.v1" + + +def test_e2e_with_subject_json() -> None: + """Pass a pre-extracted subject JSON to exercise the subject scoring path.""" + from pathlib import Path + + from poseguide.guide.e2e import run_e2e + + subject_path = Path("data/samples/subject_contrapposto.json") + if not subject_path.exists(): + # We're running from a different cwd — try relative to project root + import poseguide.config + + subject_path = poseguide.config.data_dir() / "samples" / "subject_contrapposto.json" + + summary = run_e2e( + "studio,portrait", + top_k=2, + subject_json=subject_path, + render_png=False, + ) + + assert summary["kind"] == "poseguide.e2e.v1" + assert len(summary["artifacts"]) == 2 + # Coach results should include subject_score + coach_has_score = any("subject_score" in c.get("coach", {}) for c in summary["coach"]) + assert coach_has_score, "Expected subject_score in coach bundle" + + +def test_e2e_preset_resolution() -> None: + """Known preset names are resolved to their tag strings.""" + from poseguide.guide.e2e import run_e2e + + summary = run_e2e("studio", top_k=1, render_png=False) + assert summary["tags"] == "studio,indoor,portrait,business,confident" + + +def test_e2e_tags_passthrough() -> None: + """Custom tag strings pass through unchanged.""" + from poseguide.guide.e2e import run_e2e + + summary = run_e2e("night,silhouette,urban", top_k=1, render_png=False) + assert summary["tags"] == "night,silhouette,urban" + + +def test_e2e_unknown_preset_falls_back_to_literal() -> None: + """An unknown preset-like string is used as literal tags.""" + from poseguide.guide.e2e import run_e2e + + summary = run_e2e("winter,mountains", top_k=1, render_png=False) + # Not a preset — should be passed through as-is + assert summary["tags"] == "winter,mountains" diff --git a/tests/test_extract.py b/tests/test_extract.py index 812918a..97cc732 100644 --- a/tests/test_extract.py +++ b/tests/test_extract.py @@ -26,7 +26,7 @@ class FakeLandmark: def _fake_landmarks() -> list[FakeLandmark]: """A full 33-entry MediaPipe landmark list with known values at mapped indices.""" landmarks = [FakeLandmark(0.0, 0.0, 0.0, 0.0) for _ in range(33)] - for index, key in MEDIAPIPE_LANDMARK_MAP.items(): + for index in MEDIAPIPE_LANDMARK_MAP: # Encode the index into coordinates so we can assert exact mapping. landmarks[index] = FakeLandmark( x=index / 100.0, diff --git a/tests/test_indoor_scene_pack.py b/tests/test_indoor_scene_pack.py index fc4287a..43ff021 100644 --- a/tests/test_indoor_scene_pack.py +++ b/tests/test_indoor_scene_pack.py @@ -7,7 +7,6 @@ from poseguide.data.loader import load_scene from poseguide.guide.recommend import recommend_for_scene_path - SCENES_DIR = Path(__file__).resolve().parents[1] / "data" / "scenes" SCENE_CASES = ( ("indoor_loft.json", "indoor_loft"), diff --git a/tests/test_web_demo.py b/tests/test_web_demo.py index 662aa7a..1eed1a2 100644 --- a/tests/test_web_demo.py +++ b/tests/test_web_demo.py @@ -5,7 +5,6 @@ from poseguide.data.loader import list_pose_files, list_scene_files, load_pose, load_scene - ROOT = Path(__file__).resolve().parents[1] WEB_DIR = ROOT / "web"