diff --git a/README.md b/README.md index e2a80a7..af4bf85 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,8 @@ poseguide poses list poseguide poses list --tag portrait --difficulty easy poseguide poses search --tag jump --difficulty medium poseguide scenes list +poseguide guide recommend --description "sunset beach" --no-svg +poseguide guide recommend --background photos/urban_wall.jpg --no-svg ``` SVG / overlay outputs are written under the configured `OUT_DIR` (see `poseguide.config`). @@ -112,6 +114,24 @@ poseguide demo -p studio --- +## Offline scene tagging + +`guide recommend` can infer ranker tags from a background description or image +filename without reading image pixels, making network requests, or downloading a +model: + +```powershell +poseguide guide recommend --description "studio business portrait" +poseguide guide recommend --background photos/beach_sunset.jpg +poseguide guide recommend --background photos/urban_wall.jpg --tags casual +``` + +The deterministic rules live in `poseguide.guide.scene_tagger.infer_scene_tags`. +An optional CLIP implementation can later replace that function while preserving +its `list[str]` output contract and the existing ranker integration. + +--- + ## Diagrams System architecture and workflow — full width. Open the HTML files for **dark/light theme** and export (PNG/SVG). diff --git a/src/poseguide/cli.py b/src/poseguide/cli.py index d6f1c45..7fd148b 100644 --- a/src/poseguide/cli.py +++ b/src/poseguide/cli.py @@ -13,7 +13,9 @@ from poseguide.models.catalog import get_pose_by_id from poseguide.guide.demo import PRESETS, run_demo from poseguide.guide.recommend import recommend_for_scene_path, recommend_for_tags +from poseguide.guide.scene_tagger import infer_scene_tags from poseguide.guide.score import score_subject_against_pose +from poseguide.models.toy import tags_from_text from poseguide.render.overlay import ( VisionUnavailableError, render_overlay_png, @@ -215,6 +217,17 @@ def scenes_list() -> None: def guide_recommend( scene: Optional[Path] = typer.Option(None, "--scene", "-s", exists=True, dir_okay=False), tags: Optional[str] = typer.Option(None, "--tags", "-t"), + background: Optional[Path] = typer.Option( + None, + "--background", + "-b", + exists=True, + dir_okay=False, + help="Infer scene tags from an image filename.", + ), + description: Optional[str] = typer.Option( + None, "--description", help="Infer scene tags from a background description." + ), 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"), @@ -223,13 +236,21 @@ def guide_recommend( None, "--difficulty", "-d", help="Filter by difficulty: easy, medium, hard" ), ) -> None: - if scene is None and not tags: - console.print("[red]Provide --scene or --tags[/red]") + if scene is None and not tags and background is None and not description: + console.print("[red]Provide --scene, --tags, --background, or --description[/red]") raise typer.Exit(code=1) if scene is not None: result = recommend_for_scene_path(scene, top_k=top, subject_path=subject) else: - result = recommend_for_tags(tags or "", top_k=top) + inferred_tags = infer_scene_tags(description=description, image_path=background) + scene_tags = tags_from_text(tags or "") + scene_tags.extend(tag for tag in inferred_tags if tag not in scene_tags) + if not scene_tags: + console.print("[red]No scene tags inferred; add --tags or a clearer description[/red]") + raise typer.Exit(code=1) + result = recommend_for_tags(scene_tags, top_k=top) + if inferred_tags: + result["inferred_scene_tags"] = inferred_tags if difficulty: recs = result.get("recommendations", []) before = len(recs) diff --git a/src/poseguide/guide/scene_tagger.py b/src/poseguide/guide/scene_tagger.py new file mode 100644 index 0000000..34aa4bc --- /dev/null +++ b/src/poseguide/guide/scene_tagger.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +import re +from pathlib import Path + + +_TAG_RULES = ( + (("beach", "coast", "ocean", "seaside"), ("beach", "outdoor")), + (("urban", "city", "downtown"), ("urban", "outdoor")), + (("street", "alley"), ("street", "urban", "outdoor")), + (("wall", "brick"), ("wall",)), + (("studio", "backdrop"), ("studio", "indoor")), + (("portrait", "headshot"), ("portrait",)), + (("business", "corporate"), ("business", "indoor")), + (("office", "workplace", "lobby"), ("office", "indoor", "business")), + (("forest", "woods", "woodland"), ("forest", "outdoor")), + (("park", "garden"), ("garden", "outdoor")), + (("cafe", "coffee"), ("cafe", "indoor")), + (("home", "room", "library", "loft"), ("indoor",)), + (("mountain", "trail", "hiking"), ("mountain", "outdoor")), + (("sunset", "sunrise", "golden hour"), ("golden_hour", "outdoor")), + (("night", "neon"), ("night", "urban")), + (("rooftop", "balcony"), ("rooftop", "urban", "outdoor")), + (("sports", "field", "stadium"), ("sports", "outdoor")), + (("subway", "metro", "station"), ("subway", "urban", "indoor")), + (("festival", "crowd"), ("festival", "outdoor")), + (("harbor", "pier", "lakeside"), ("waterfront", "outdoor")), +) + + +def infer_scene_tags( + *, description: str | None = None, image_path: Path | None = None +) -> list[str]: + """Infer ranker-compatible scene tags from text and an image filename.""" + sources = [description or ""] + if image_path is not None: + sources.append(image_path.stem) + normalized = re.sub(r"[^a-z0-9]+", " ", " ".join(sources).lower()).strip() + if not normalized: + return [] + + haystack = f" {normalized} " + tags: list[str] = [] + for keywords, inferred_tags in _TAG_RULES: + if not any(f" {keyword} " in haystack for keyword in keywords): + continue + for tag in inferred_tags: + if tag not in tags: + tags.append(tag) + return tags diff --git a/tests/test_scene_tagger.py b/tests/test_scene_tagger.py new file mode 100644 index 0000000..338d22a --- /dev/null +++ b/tests/test_scene_tagger.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +from typer.testing import CliRunner + +from poseguide import cli +from poseguide.guide.scene_tagger import infer_scene_tags + + +runner = CliRunner() + + +def test_infer_scene_tags_from_description() -> None: + tags = infer_scene_tags(description="Portrait at a beach during sunset") + + assert tags == ["beach", "outdoor", "portrait", "golden_hour"] + + +def test_infer_scene_tags_from_image_fixture(tmp_path) -> None: + image = tmp_path / "urban_brick_wall.jpg" + image.write_bytes(b"offline image fixture") + + tags = infer_scene_tags(image_path=image) + + assert tags == ["urban", "outdoor", "wall"] + + +def test_guide_recommend_uses_inferred_tags(tmp_path, monkeypatch) -> None: + monkeypatch.setattr(cli, "OUT_DIR", tmp_path) + + result = runner.invoke( + cli.app, + ["guide", "recommend", "--description", "sunset beach", "--no-svg"], + ) + + assert result.exit_code == 0 + assert '"inferred_scene_tags":' in result.output + assert '"beach"' in result.output + assert '"golden_hour"' in result.output + + +def test_guide_recommend_rejects_unknown_description() -> None: + result = runner.invoke( + cli.app, + ["guide", "recommend", "--description", "plain backdropless photograph", "--no-svg"], + ) + + assert result.exit_code == 1 + assert "No scene tags inferred" in result.output