diff --git a/tools/sprite-generation/cli.py b/tools/sprite-generation/cli.py index 53223dae..d6bf588a 100644 --- a/tools/sprite-generation/cli.py +++ b/tools/sprite-generation/cli.py @@ -26,9 +26,9 @@ RAW_DIR = TOOL_DIR / "raw" VARIANTS_DIR = TOOL_DIR / "variants" HEX_MASK_PATH = TOOL_DIR / "hex_mask.png" -LOCAL_DATA = PROJECT / "games" / "age-of-four" / "data" +LOCAL_DATA = PROJECT / "games" / "age-of-dwarves" / "data" DEMO_DATA = TOOL_DIR / "demo-data" -ASSETS_DIR = PROJECT / "games" / "age-of-four" / "assets" +ASSETS_DIR = PROJECT / "games" / "age-of-dwarves" / "assets" def _data_dir(args: argparse.Namespace) -> Path: @@ -129,10 +129,13 @@ def cmd_generate(args: argparse.Namespace) -> None: print(f" ... and {len(sprite_ids) - 50} more") return + pose_ref = Path(args.pose_ref) if args.pose_ref else None completed = asyncio.run(gen.generate_batch( sprite_ids=sprite_ids, variants_per=args.variants, priority=args.priority, + pose_reference=pose_ref, + img2img_strength=args.strength, )) print(f"\nGenerated {completed} images. Run 'rank' to score them.") @@ -169,6 +172,24 @@ def cmd_install(args: argparse.Namespace) -> None: print(f"\n{'Would install' if args.dry_run else 'Installed'}: {count} sprites") +def cmd_approve(args: argparse.Namespace) -> None: + from engine.pipeline import SpritePipeline + + reg = _registry() + pipeline = SpritePipeline( + registry=reg, + raw_dir=RAW_DIR, + variants_dir=VARIANTS_DIR, + assets_dir=ASSETS_DIR, + game_db_path=LOCAL_DATA / "sprites.db", + ) + result = pipeline.approve_and_install(args.variant, alt_name=args.alt) + if result: + print(f"\nShipped: {result}") + else: + print("\nFailed to install.") + + def cmd_reset(args: argparse.Namespace) -> None: reg = _registry() if not args.sprite: @@ -195,6 +216,136 @@ def cmd_review(args: argparse.Namespace) -> None: print("Install dependencies: pip install fastapi uvicorn") +def cmd_run(args: argparse.Namespace) -> None: + """Orchestrate the full generate → rank → regen loop continuously. + + Runs until all sprites are in review (ranked) or installed. + The human reviews and approves via the GUI at localhost:5850. + """ + import asyncio + import time + from engine.generator import SpriteGenerator + from engine.ranker import SpriteRanker + + reg = _registry() + config = _load_config() + gen = SpriteGenerator(config=config, registry=reg, raw_dir=RAW_DIR) + ranker = SpriteRanker(registry=reg, raw_dir=RAW_DIR) + + pose_ref = Path(args.pose_ref) if args.pose_ref else None + batch_size = args.batch or 4 + variants_per = args.variants + + # Start GUI server in background thread + import threading + def _serve(): + from server import create_app + import uvicorn + app = create_app(registry=reg, raw_dir=RAW_DIR, variants_dir=VARIANTS_DIR) + uvicorn.run(app, host="0.0.0.0", port=args.port, log_level="warning") + + server_thread = threading.Thread(target=_serve, daemon=True) + server_thread.start() + print(f"Review GUI: http://localhost:{args.port}/?spriteTheater=true") + + loop_count = 0 + while True: + loop_count += 1 + + # --- Phase 1: Generate --- + needed = reg.get_sprites( + category=args.category, + status="needed", + limit=batch_size, + ) + + if needed: + sprite_ids = [s["id"] for s in needed] + print(f"\n[loop {loop_count}] Generating {len(sprite_ids)} sprites × {variants_per} variants...") + completed = asyncio.run(gen.generate_batch( + sprite_ids=sprite_ids, + variants_per=variants_per, + priority="high", + pose_reference=pose_ref, + img2img_strength=args.strength, + )) + print(f" Generated {completed} images") + + # --- Phase 2: Rank unranked review sprites --- + unranked = reg.conn.execute(""" + SELECT DISTINCT s.id FROM sprites s + WHERE s.status = 'review' + AND NOT EXISTS ( + SELECT 1 FROM variants v + WHERE v.sprite_id = s.id + AND v.job_status = 'completed' + AND v.notes IS NOT NULL + ) + LIMIT ? + """, (batch_size,)).fetchall() + + if unranked: + print(f"\n[loop {loop_count}] Ranking {len(unranked)} unranked sprites...") + for row in unranked: + sprite_id = row["id"] + result = asyncio.run(ranker.rank_and_filter(sprite_id)) + good = result["good_count"] + total = len(result["ranked"]) + if result["needs_regen"]: + reg.update_sprite_status(sprite_id, "needed") + print(f" {sprite_id}: {good}/{total} good — needs regen, reset to needed") + else: + print(f" {sprite_id}: {good}/{total} good — ready for review") + + # --- Phase 3: Re-rank previously ranked but deficient sprites --- + # (sprites in review that HAVE been ranked but didn't pass) + deficient = reg.conn.execute(""" + SELECT DISTINCT s.id FROM sprites s + WHERE s.status = 'review' + AND EXISTS ( + SELECT 1 FROM variants v + WHERE v.sprite_id = s.id + AND v.job_status = 'completed' + AND v.notes IS NOT NULL + ) + LIMIT ? + """, (batch_size,)).fetchall() + + # Only re-check deficient if nothing else to do + if not needed and not unranked and deficient: + print(f"\n[loop {loop_count}] Re-checking {len(deficient)} ranked sprites...") + for row in deficient: + sprite_id = row["id"] + result = asyncio.run(ranker.rank_and_filter(sprite_id)) + if result["needs_regen"]: + reg.update_sprite_status(sprite_id, "needed") + print(f" {sprite_id}: still deficient, reset to needed") + + # --- Status --- + stats = reg.get_stats() + total_row = stats["total"] + needed_count = total_row.get("needed", 0) + review_count = total_row.get("review", 0) + approved_count = total_row.get("approved", 0) + installed_count = total_row.get("installed", 0) + total_count = sum(total_row.values()) + + done_count = approved_count + installed_count + print(f"\n[loop {loop_count}] Status: {needed_count} needed, {review_count} review, {done_count} done / {total_count} total") + + if needed_count == 0 and review_count == 0: + print("\nAll sprites processed. Waiting for new work...") + + # --- Pause between loops --- + pause = 10 if needed else 30 + print(f" Next loop in {pause}s... (Ctrl+C to stop)") + try: + time.sleep(pause) + except KeyboardInterrupt: + print("\nStopping pipeline.") + break + + def cmd_export(args: argparse.Namespace) -> None: import csv import io @@ -223,7 +374,17 @@ def main() -> None: parser.add_argument("--demo", action="store_true", help="Use minimal demo data (1 per domain)") sub = parser.add_subparsers(dest="command") - # start (default) — launch GUI server + # run — full pipeline orchestrator + p = sub.add_parser("run", help="Run full pipeline: generate → rank → regen loop + GUI") + p.add_argument("--port", type=int, default=5850, help="GUI server port (default: 5850)") + p.add_argument("--category", type=str, help="Only process one category") + p.add_argument("--variants", type=int, default=4, help="Variants per sprite (default: 4)") + p.add_argument("--batch", type=int, default=4, help="Sprites per batch (default: 4)") + p.add_argument("--pose-ref", type=str, help="Pose reference image for img2img") + p.add_argument("--strength", type=float, default=0.6, help="img2img strength (default: 0.6)") + p.set_defaults(func=cmd_run) + + # start — launch GUI server only p = sub.add_parser("start", help="Launch review GUI server") p.add_argument("--port", type=int, default=5850, help="Server port (default: 5850)") p.set_defaults(func=cmd_review) @@ -244,6 +405,8 @@ def main() -> None: p.add_argument("--priority", choices=["low", "normal", "high"], default="normal") p.add_argument("--max", type=int, help="Max sprites to generate") p.add_argument("--dry-run", action="store_true", help="Show what would be generated") + p.add_argument("--pose-ref", type=str, help="Path to pose reference image for img2img (consistent facing)") + p.add_argument("--strength", type=float, default=0.75, help="img2img denoising strength (0=copy, 1=ignore ref)") p.set_defaults(func=cmd_generate) # rank @@ -258,6 +421,12 @@ def main() -> None: p.add_argument("--dry-run", action="store_true") p.set_defaults(func=cmd_install) + # approve + p = sub.add_parser("approve", help="Approve variant → process → install → manifest") + p.add_argument("variant", type=int, help="Variant ID to approve (e.g. 129)") + p.add_argument("--alt", type=str, help="Install as alternate (e.g. --alt front)") + p.set_defaults(func=cmd_approve) + # reset p = sub.add_parser("reset", help="Reset sprite back to needed status") p.add_argument("--sprite", type=str, required=True, help="Sprite ID to reset") diff --git a/tools/sprite-generation/server.py b/tools/sprite-generation/server.py index f6196fde..55150c83 100644 --- a/tools/sprite-generation/server.py +++ b/tools/sprite-generation/server.py @@ -130,6 +130,11 @@ def create_app( registry.update_sprite_status(sprite_id, "skip") return {"status": "skip"} + @app.post("/api/variants/{variant_id:int}/reject") + def reject_variant(variant_id: int) -> dict: + registry.reject_variant(variant_id) + return {"status": "rejected", "variant_id": variant_id} + @app.post("/api/sprites/{sprite_id:path}/regenerate") def regenerate_sprite(sprite_id: str, body: RegenerateRequest) -> dict: sprite = registry.get_sprite(sprite_id) @@ -182,7 +187,7 @@ def create_app( @app.get("/api/variants/recent") def get_recent_variants( - limit: Annotated[int, Query(ge=1, le=100)] = 30, + limit: Annotated[int, Query(ge=1, le=5000)] = 30, ) -> list[dict]: return registry.get_recent_variants(limit=limit) diff --git a/tools/sprite-generation/sprites.db b/tools/sprite-generation/sprites.db index 2914153a..d6c5b1b1 100644 Binary files a/tools/sprite-generation/sprites.db and b/tools/sprite-generation/sprites.db differ diff --git a/tools/sprite-generation/sprites.db-shm b/tools/sprite-generation/sprites.db-shm index 6779cb35..da840941 100644 Binary files a/tools/sprite-generation/sprites.db-shm and b/tools/sprite-generation/sprites.db-shm differ diff --git a/tools/sprite-generation/sprites.db-wal b/tools/sprite-generation/sprites.db-wal index 6ff100df..dbac5fca 100644 Binary files a/tools/sprite-generation/sprites.db-wal and b/tools/sprite-generation/sprites.db-wal differ