Spaces:
Sleeping
Sleeping
| from __future__ import annotations | |
| """ | |
| Extract a frame JSON (hand cards + elixir) from a BlueStacks screenshot, using: | |
| - local OCR for elixir (calibrated ROI) | |
| - local template matching for hand cards (deck icons from clash_royale_pack_for_mac/icons) | |
| This is inspired by the schema in: | |
| clash_royale_pack_for_mac/outputs/frame_with_templates.json | |
| Usage: | |
| cd toxic_royale_env | |
| python3 scripts/bluestacks_frame_extractor.py --image outputs/bluestacks/pilot_current.png | |
| Debug: | |
| BS_DEBUG_FRAME=1 python3 scripts/bluestacks_frame_extractor.py --image ... | |
| -> writes outputs/bluestacks/debug_hand_slot_*.png | |
| """ | |
| import argparse | |
| import json | |
| import os | |
| from dataclasses import dataclass | |
| from pathlib import Path | |
| import numpy as np | |
| from PIL import Image | |
| def _load_json(path: Path) -> dict: | |
| return json.loads(path.read_text(encoding="utf-8")) | |
| def _load_elixir_reader(root: Path): | |
| # Local import without package requirements | |
| import importlib.util | |
| import sys | |
| mod_path = root / "scripts" / "bluestacks_elixir_ocr.py" | |
| spec = importlib.util.spec_from_file_location("bluestacks_elixir_ocr", mod_path) | |
| if spec is None or spec.loader is None: | |
| raise RuntimeError(f"Could not load module from {mod_path}") | |
| module = importlib.util.module_from_spec(spec) | |
| sys.modules[spec.name] = module | |
| spec.loader.exec_module(module) | |
| fn = getattr(module, "read_elixir", None) | |
| if fn is None: | |
| raise RuntimeError("Missing read_elixir() in bluestacks_elixir_ocr.py") | |
| return fn | |
| def _bluestacks_bounds() -> tuple[int, int, int, int] | None: | |
| """ | |
| Must match scripts/bluestacks_screenshot.sh bounds logic. | |
| """ | |
| import subprocess | |
| script = r''' | |
| tell application "System Events" | |
| try | |
| set bluestacksProc to application process "BlueStacks" | |
| set allWins to every window of bluestacksProc | |
| repeat with w in allWins | |
| set t to title of w | |
| set n to name of w | |
| set sr to subrole of w | |
| set s to size of w | |
| set p to position of w | |
| if (t is "BlueStacks Air" or n is "BlueStacks Air") and sr is "AXDialog" then | |
| set x to item 1 of p | |
| set y to item 2 of p | |
| set w_ to item 1 of s | |
| set h to item 2 of s | |
| if h > 200 then | |
| return "" & x & "," & y & "," & w_ & "," & h | |
| end if | |
| end if | |
| end repeat | |
| return "" | |
| on error | |
| return "" | |
| end try | |
| end tell | |
| ''' | |
| try: | |
| out = subprocess.check_output(["osascript", "-e", script], text=True).strip() | |
| if not out or out.count(",") < 3: | |
| return None | |
| x_s, y_s, w_s, h_s = out.split(",", 3) | |
| return int(float(x_s)), int(float(y_s)), int(float(w_s)), int(float(h_s)) | |
| except Exception: | |
| return None | |
| class Match: | |
| card: str | |
| conf: float | |
| ICON_MATCH_SIZE = (96, 96) | |
| def _prep_gray(im: Image.Image, *, top_drop_frac: float) -> Image.Image: | |
| """ | |
| Make matching robust to borders/UI by: | |
| - dropping some of the top region | |
| - center-cropping | |
| - converting to grayscale | |
| """ | |
| w, h = im.size | |
| y0 = int(round(h * top_drop_frac)) | |
| im0 = im.crop((0, y0, w, h)) | |
| w2, h2 = im0.size | |
| cx0 = int(round(w2 * 0.10)) | |
| cy0 = int(round(h2 * 0.10)) | |
| cx1 = int(round(w2 * 0.90)) | |
| cy1 = int(round(h2 * 0.95)) | |
| return im0.crop((cx0, cy0, cx1, cy1)).convert("L") | |
| def _to_norm_vec(im: Image.Image, *, top_drop_frac: float) -> np.ndarray: | |
| g = _prep_gray(im, top_drop_frac=top_drop_frac).resize(ICON_MATCH_SIZE, Image.Resampling.BICUBIC) | |
| arr = np.asarray(g, dtype=np.float32).reshape(-1) | |
| arr -= float(arr.mean()) | |
| n = float(np.linalg.norm(arr) + 1e-6) | |
| return arr / n | |
| def _load_templates(pack_dir: Path) -> dict[str, np.ndarray]: | |
| """ | |
| Loads deck icons as grayscale arrays (no evo / no card_templates). | |
| """ | |
| icon_dir = pack_dir / "icons" | |
| out: dict[str, np.ndarray] = {} | |
| # Deck-only matching (reduces false positives a lot). | |
| want = { | |
| "knight", | |
| "goblins", | |
| "minions", | |
| "bomber", | |
| "fireball", | |
| "goblin-hut", | |
| "musketeer", | |
| "mini-pekka", | |
| } | |
| def _icon_path_for(key: str) -> Path: | |
| # most are snake_case | |
| base = key.replace("-", "_") | |
| candidates = [ | |
| f"{base}.png", | |
| # special naming in this pack | |
| "mini_p_e_k_k_a.png" if key == "mini-pekka" else "", | |
| "p_e_k_k_a.png" if key == "pekka" else "", | |
| ] | |
| for name in candidates: | |
| if not name: | |
| continue | |
| p = icon_dir / name | |
| if p.exists(): | |
| return p | |
| return icon_dir / f"{base}.png" | |
| # Icons are always available for these. | |
| for key in sorted(want): | |
| ip = _icon_path_for(key) | |
| if ip.exists(): | |
| out[key] = _to_norm_vec(Image.open(ip), top_drop_frac=0.0) | |
| else: | |
| raise RuntimeError(f"missing_deck_icon::{ip}") | |
| return out | |
| def _hand_slot_boxes_px(cfg: dict, *, img_w: int, img_h: int) -> list[tuple[int, int, int, int]]: | |
| """ | |
| Compute 4 hand slot crop boxes in screenshot pixel coords. | |
| We use calibrated card_slots centers (screen coords) and map to screenshot pixels | |
| via BlueStacks bounds + Retina scale. | |
| """ | |
| b = _bluestacks_bounds() | |
| if b is None: | |
| raise RuntimeError("cannot_find_bluestacks_bounds") | |
| ox, oy, w_pt, h_pt = b | |
| sx = img_w / max(1.0, float(w_pt)) | |
| sy = img_h / max(1.0, float(h_pt)) | |
| centers = [] | |
| for k in ["1", "2", "3", "4"]: | |
| x_s, y_s = (cfg.get("card_slots") or {}).get(k) or [0, 0] | |
| cx = int(round((int(x_s) - ox) * sx)) | |
| cy = int(round((int(y_s) - oy) * sy)) | |
| centers.append((cx, cy)) | |
| # Estimate slot width from distances between centers. | |
| dxs = [abs(centers[i + 1][0] - centers[i][0]) for i in range(3)] | |
| dx = int(round(float(sorted(dxs)[1]) if len(dxs) >= 3 else (dxs[0] if dxs else 120))) | |
| # Card art crop: target the top artwork region (avoid the bottom cost bubble). | |
| # We use a square crop and shift it upward relative to the slot center. | |
| w_box = int(round(dx * 0.78)) | |
| h_box = w_box | |
| y_up = int(round(h_box * 0.78)) | |
| y_dn = int(round(h_box * 0.22)) | |
| y_shift_up = int(round(dx * 0.22)) | |
| boxes = [] | |
| for cx, cy in centers: | |
| cy2 = cy - y_shift_up | |
| x0 = max(0, cx - w_box // 2) | |
| x1 = min(img_w, cx + w_box // 2) | |
| y0 = max(0, cy2 - y_up) | |
| y1 = min(img_h, cy2 + y_dn) | |
| boxes.append((x0, y0, x1, y1)) | |
| return boxes | |
| def main() -> int: | |
| ap = argparse.ArgumentParser() | |
| ap.add_argument("--image", type=str, required=True) | |
| args = ap.parse_args() | |
| root = Path(__file__).resolve().parents[1] | |
| pack_dir = root / "clash_royale_pack_for_mac" | |
| cfg = _load_json(root / "config" / "bluestacks_gameplay.local.json") | |
| img_path = Path(args.image) | |
| im = Image.open(img_path).convert("RGB") | |
| read_elixir = _load_elixir_reader(root) | |
| elixir, el_dbg = read_elixir(img_path) | |
| # templates | |
| templates = _load_templates(pack_dir) | |
| boxes = _hand_slot_boxes_px(cfg, img_w=im.size[0], img_h=im.size[1]) | |
| debug = os.environ.get("BS_DEBUG_FRAME", "0") == "1" | |
| if debug: | |
| out_dir = root / "outputs" / "bluestacks" | |
| out_dir.mkdir(parents=True, exist_ok=True) | |
| hand = [] | |
| for idx, box in enumerate(boxes): | |
| crop = im.crop(box) | |
| search_vec = _to_norm_vec(crop, top_drop_frac=0.18) | |
| best = Match(card="unknown", conf=-1.0) | |
| for card, tpl_vec in templates.items(): | |
| s = float(np.dot(search_vec, tpl_vec)) | |
| if s > best.conf: | |
| best = Match(card=card, conf=s) | |
| # Map template key -> dataset key/cost | |
| # Our pack uses kebab-case stems; dataset.json has names but we can map for known 8. | |
| cost_map = { | |
| "knight": 3, | |
| "goblins": 2, | |
| "minions": 3, | |
| "bomber": 2, | |
| "fireball": 4, | |
| "goblin-hut": 4, | |
| "musketeer": 4, | |
| "mini-pekka": 4, | |
| "archers": 3, | |
| "arrows": 3, | |
| "giant": 5, | |
| } | |
| cost = cost_map.get(best.card) | |
| playable = (elixir is not None) and (cost is not None) and (float(cost) <= float(elixir)) | |
| hand.append( | |
| { | |
| "slot": idx, | |
| "card": best.card, | |
| "cost": cost, | |
| "is_playable": bool(playable), | |
| "cooldown_ms": 0, | |
| "bbox_x1": int(box[0]), | |
| "bbox_y1": int(box[1]), | |
| "bbox_x2": int(box[2]), | |
| "bbox_y2": int(box[3]), | |
| "conf": float(best.conf), | |
| } | |
| ) | |
| if debug: | |
| out_dir = root / "outputs" / "bluestacks" | |
| crop.save(out_dir / f"debug_hand_slot_{idx}_{best.card}.png") | |
| frame = { | |
| "meta": {"source": {"device": "mac", "emulator": "bluestacks", "game": "clash_royale"}}, | |
| "ui": {"screen": "battle"}, | |
| "player": { | |
| "elixir": elixir, | |
| "hand": hand, | |
| "debug_elixir": el_dbg, | |
| }, | |
| } | |
| print(json.dumps(frame, indent=2)) | |
| return 0 | |
| if __name__ == "__main__": | |
| raise SystemExit(main()) | |