from __future__ import annotations """ Local elixir reading using OCR + bar fill (fallback). Designed for BlueStacks screenshot produced by scripts/bluestacks_screenshot.sh. Dependencies: - Pillow (already used elsewhere) - Optional: tesseract + pytesseract for best results - brew install tesseract - python3 -m pip install pytesseract Usage: cd toxic_royale_env python3 scripts/bluestacks_elixir_ocr.py --image outputs/bluestacks/pilot_current.png Debug: BS_DEBUG_ELIXIR=1 python3 scripts/bluestacks_elixir_ocr.py --image ... -> writes outputs/bluestacks/debug_elixir_ocr_roi.png and debug_elixir_bar_roi.png """ import argparse import json import os import subprocess from pathlib import Path from PIL import Image, ImageOps, ImageFilter def _load_cfg(root: Path) -> dict: cfg_path = root / "config" / "bluestacks_gameplay.local.json" return json.loads(cfg_path.read_text(encoding="utf-8")) def _bluestacks_bounds() -> tuple[int, int, int, int] | None: """ Return (x,y,w,h) of the exact region captured by scripts/bluestacks_screenshot.sh. This lets us convert absolute screen calibration coords -> screenshot pixel coords. """ 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 def _crop_elixir_number_roi(im: Image.Image, *, x_left: int, y: int) -> Image.Image: """ Crop ROI around the big elixir number (white). Important: BlueStacks UI is consistent inside the cropped screenshot, and calibration y can drift. We therefore default to a RELATIVE crop (percentage of image size) which is robust. """ w, h = im.size # Relative ROI covering the big elixir number + "Max: 10" baseline. # Tuned against typical BlueStacks Air layout (and avoids the hand card costs). # Default to a bar-anchored crop (more stable than a pure relative crop). # The big number sits ABOVE the bar and slightly LEFT of the bar start. # Keep this fairly right-shifted to avoid the "Next:" label region. x0 = max(0, x_left - 170) x1 = max(0, min(w, x_left + 20)) # Place the crop just above the bar, where the big elixir number sits. y0 = max(0, y - 175) y1 = max(0, min(h, y - 20)) # If user wants to force calibration-based crop for experiments: if os.environ.get("BS_OCR_MODE", "").strip().lower() == "relative": # Relative ROI covering the elixir droplet region; useful if calibration is very wrong. x0 = int(round(0.36 * w)) x1 = int(round(0.50 * w)) y0 = int(round(0.85 * h)) y1 = int(round(0.95 * h)) x0 = max(0, min(w - 1, x0)) x1 = max(x0 + 1, min(w, x1)) y0 = max(0, min(h - 1, y0)) y1 = max(y0 + 1, min(h, y1)) return im.crop((x0, y0, x1, y1)) def _preprocess_for_ocr(roi: Image.Image) -> Image.Image: g = ImageOps.grayscale(roi) # Upscale so OCR has more pixels. g = g.resize((g.size[0] * 3, g.size[1] * 3), Image.Resampling.BICUBIC) # Sharpen edges a bit. g = g.filter(ImageFilter.UnsharpMask(radius=2, percent=180, threshold=2)) # High contrast: white digits on darker background. g = ImageOps.autocontrast(g) # Binarize (slightly lower threshold; BlueStacks UI can be dim). g = g.point(lambda p: 255 if p > 140 else 0) return g def _try_tesseract(roi_bin: Image.Image) -> int | None: try: import pytesseract # type: ignore except Exception: return None try: txt = pytesseract.image_to_string( roi_bin, config="--psm 7 -c tessedit_char_whitelist=0123456789", ).strip() except Exception: return None # Extract first integer-like token digits = "".join(ch for ch in txt if ch.isdigit()) if not digits: return None try: v = int(digits) except Exception: return None if 0 <= v <= 10: return v return None def _estimate_from_bar(im: Image.Image, *, x0: int, x1: int, y: int) -> float | None: """ Estimate elixir 0..10 from bar fill (left->right length). Uses HSV scoring + adaptive threshold and searches nearby y positions to avoid sampling the wrong scanline (Retina + UI glow). """ w, h = im.size x0 = max(0, min(w - 2, x0)) x1 = max(x0 + 1, min(w, x1)) import colorsys def magenta_score(r: int, g: int, b: int) -> float: rf, gf, bf = r / 255.0, g / 255.0, b / 255.0 h_, s_, v_ = colorsys.rgb_to_hsv(rf, gf, bf) # magenta/purple hue band if 0.78 <= h_ <= 0.95 and s_ >= 0.25 and v_ >= 0.15: return s_ * v_ return 0.0 best_fill = 0.0 best_roi = None # scan a few y offsets around calibrated y for dy in range(-24, 25, 2): yy = max(0, min(h - 16, y + dy)) roi = im.crop((x0, yy, x1, yy + 12)).convert("RGB") px = roi.load() cols = roi.size[0] band = roi.size[1] scores: list[float] = [] for cx in range(cols): ssum = 0.0 for cy in range(band): r, g, b = px[cx, cy] ssum += magenta_score(r, g, b) scores.append(ssum / max(1, band)) s_sorted = sorted(scores) p10 = s_sorted[int(0.10 * (len(s_sorted) - 1))] p90 = s_sorted[int(0.90 * (len(s_sorted) - 1))] if p90 <= 0.02: continue thr = (p10 + p90) / 2.0 # find left-to-right fill run, allowing small gaps for tick marks filled_cols = 0 miss_run = 0 for v in scores: if v >= thr: filled_cols += 1 miss_run = 0 else: miss_run += 1 if miss_run >= 10: break fill = filled_cols / max(1, cols) if fill > best_fill: best_fill = fill best_roi = roi if os.environ.get("BS_DEBUG_ELIXIR", "0") == "1" and best_roi is not None: out_dir = Path(__file__).resolve().parents[1] / "outputs" / "bluestacks" out_dir.mkdir(parents=True, exist_ok=True) best_roi.save(out_dir / "debug_elixir_bar_roi.png") if best_fill <= 0.0: return None return round(10.0 * max(0.0, min(1.0, best_fill)), 1) def read_elixir(image_path: Path) -> tuple[float | None, dict]: root = Path(__file__).resolve().parents[1] cfg = _load_cfg(root) eb = cfg.get("elixir_bar") or {} s = eb.get("start") e = eb.get("end") if not (s and e): return None, {"error": "missing_elixir_bar_calibration"} im = Image.open(image_path).convert("RGB") # Convert absolute screen coords -> screenshot coords using the BlueStacks window origin. b = _bluestacks_bounds() if b is None: return None, {"error": "cannot_find_bluestacks_bounds"} ox, oy, _w, _h = b # Retina scaling: screencapture produces pixel-dense images, while cliclick/AppleScript coords # are in logical screen points. Compute scale from captured image size vs window bounds. sx = im.size[0] / max(1.0, float(_w)) sy = im.size[1] / max(1.0, float(_h)) x0_pt, y_pt = int(s[0]) - ox, int(s[1]) - oy x1_pt, _y2_pt = int(e[0]) - ox, int(e[1]) - oy x0 = int(round(x0_pt * sx)) y = int(round(y_pt * sy)) x1 = int(round(x1_pt * sx)) dbg_bounds = { "bounds": [ox, oy, _w, _h], "scale": [round(sx, 3), round(sy, 3)], "bar_start_xy_points": [x0_pt, y_pt], "bar_end_x_points": x1_pt, "bar_start_xy_px": [x0, y], "bar_end_x_px": x1, } if x1 < x0: x0, x1 = x1, x0 # Optional: ROI calibration (screen coords) for number + bar. # If present (non-zero), use these boxes instead of derived ones. roi_cfg = cfg.get("elixir_roi") or {} num_tl = (roi_cfg.get("number") or {}).get("tl") or [0, 0] num_br = (roi_cfg.get("number") or {}).get("br") or [0, 0] bar_tl = (roi_cfg.get("bar") or {}).get("tl") or [0, 0] bar_br = (roi_cfg.get("bar") or {}).get("br") or [0, 0] def _nonzero(pt) -> bool: try: return int(pt[0]) != 0 or int(pt[1]) != 0 except Exception: return False # Convert screen-ROI -> screenshot pixels (apply origin + scale) num_box = None if _nonzero(num_tl) and _nonzero(num_br): x0n = int(round((int(num_tl[0]) - ox) * sx)) y0n = int(round((int(num_tl[1]) - oy) * sy)) x1n = int(round((int(num_br[0]) - ox) * sx)) y1n = int(round((int(num_br[1]) - oy) * sy)) num_box = (min(x0n, x1n), min(y0n, y1n), max(x0n, x1n), max(y0n, y1n)) bar_box = None if _nonzero(bar_tl) and _nonzero(bar_br): x0b = int(round((int(bar_tl[0]) - ox) * sx)) y0b = int(round((int(bar_tl[1]) - oy) * sy)) x1b = int(round((int(bar_br[0]) - ox) * sx)) y1b = int(round((int(bar_br[1]) - oy) * sy)) bar_box = (min(x0b, x1b), min(y0b, y1b), max(x0b, x1b), max(y0b, y1b)) # 1) OCR number (best) if num_box is not None: roi_num = im.crop(num_box) roi_num_box = list(num_box) else: roi_num = _crop_elixir_number_roi(im, x_left=x0, y=y) roi_num_box = "auto" roi_bin = _preprocess_for_ocr(roi_num) v_int = _try_tesseract(roi_bin) # 2) Bar fill fallback if bar_box is not None: x0b, y0b, x1b, y1b = bar_box # use centerline y for scanning, but keep x range tight yb = int((y0b + y1b) / 2) v_bar = _estimate_from_bar(im, x0=x0b, x1=x1b, y=yb) bar_box_dbg = list(bar_box) else: v_bar = _estimate_from_bar(im, x0=x0, x1=x1, y=y) bar_box_dbg = "auto" dbg = { "ocr_int": v_int, "bar_est": v_bar, "roi_num_box": roi_num_box, "bar_box": bar_box_dbg, **dbg_bounds, } if os.environ.get("BS_DEBUG_ELIXIR", "0") == "1": out_dir = root / "outputs" / "bluestacks" out_dir.mkdir(parents=True, exist_ok=True) roi_bin.save(out_dir / "debug_elixir_ocr_roi.png") dbg["debug_dir"] = str(out_dir) if v_int is not None: return float(v_int), dbg if v_bar is not None: return float(v_bar), dbg return None, dbg def main() -> int: ap = argparse.ArgumentParser() ap.add_argument("--image", type=str, required=True) args = ap.parse_args() v, dbg = read_elixir(Path(args.image)) print(json.dumps({"elixir": v, "debug": dbg}, indent=2)) return 0 if __name__ == "__main__": raise SystemExit(main())