Spaces:
Sleeping
Sleeping
File size: 9,410 Bytes
b0620f3 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 | 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
@dataclass(frozen=True)
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())
|