Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`).
Expand Down Expand Up @@ -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).
Expand Down
27 changes: 24 additions & 3 deletions src/poseguide/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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"),
Expand All @@ -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)
Expand Down
50 changes: 50 additions & 0 deletions src/poseguide/guide/scene_tagger.py
Original file line number Diff line number Diff line change
@@ -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
48 changes: 48 additions & 0 deletions tests/test_scene_tagger.py
Original file line number Diff line number Diff line change
@@ -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