184 lines
7.1 KiB
Python
184 lines
7.1 KiB
Python
"""
|
|
OCR utility module using Windows Native OCR (Windows.Media.Ocr via winsdk).
|
|
Fast, reliable, offline, and requires no external binaries.
|
|
"""
|
|
|
|
import asyncio
|
|
import os
|
|
import re
|
|
from typing import Optional, Tuple
|
|
import cv2
|
|
import numpy as np
|
|
|
|
import winsdk.windows.globalization as glob
|
|
import winsdk.windows.graphics.imaging as imaging
|
|
import winsdk.windows.media.ocr as win_ocr
|
|
import winsdk.windows.storage.streams as streams
|
|
|
|
|
|
class OcrReader:
|
|
def __init__(self, lang_tag: str = "en-US"):
|
|
self.lang_tag = lang_tag
|
|
self._engine = None
|
|
|
|
def _get_engine(self):
|
|
if self._engine is None:
|
|
lang = glob.Language(self.lang_tag)
|
|
self._engine = win_ocr.OcrEngine.try_create_from_language(lang)
|
|
if not self._engine:
|
|
self._engine = win_ocr.OcrEngine.try_create_from_user_profile_languages()
|
|
return self._engine
|
|
|
|
async def _recognize_async(self, bgr_image: np.ndarray) -> str:
|
|
engine = self._get_engine()
|
|
bgra = cv2.cvtColor(bgr_image, cv2.COLOR_BGR2BGRA)
|
|
success, encoded = cv2.imencode(".png", bgra)
|
|
if not success:
|
|
return ""
|
|
|
|
bytes_data = encoded.tobytes()
|
|
stream = streams.InMemoryRandomAccessStream()
|
|
writer = streams.DataWriter(stream)
|
|
writer.write_bytes(bytes_data)
|
|
await writer.store_async()
|
|
await writer.flush_async()
|
|
stream.seek(0)
|
|
|
|
decoder = await imaging.BitmapDecoder.create_async(stream)
|
|
software_bitmap = await decoder.get_software_bitmap_async()
|
|
result = await engine.recognize_async(software_bitmap)
|
|
return result.text.strip()
|
|
|
|
def read_text(self, bgr_image: np.ndarray) -> str:
|
|
"""Synchronously recognizes text from a BGR numpy image."""
|
|
try:
|
|
return asyncio.run(self._recognize_async(bgr_image))
|
|
except Exception:
|
|
# Fallback for environments with active event loop
|
|
loop = asyncio.new_event_loop()
|
|
return loop.run_until_complete(self._recognize_async(bgr_image))
|
|
|
|
def read_trophies(self, frame: np.ndarray) -> Optional[Tuple[int, int]]:
|
|
"""
|
|
Extracts trophies counter (current, max) from TOWER_LOBBY screen.
|
|
Expected format: '2125/2300' -> returns (2125, 2300).
|
|
Uses relative coordinates (0.2634..0.3171 y, 0.8151..0.9115 x) to support any resolution.
|
|
"""
|
|
fh, fw = frame.shape[:2]
|
|
if fh < 300 or fw < 500:
|
|
return None
|
|
|
|
y1, y2 = int(fh * 0.2634), int(fh * 0.3171)
|
|
x1, x2 = int(fw * 0.8151), int(fw * 0.9115)
|
|
trophy_crop = frame[y1:y2, x1:x2]
|
|
|
|
if trophy_crop.shape[0] < 80:
|
|
trophy_crop = cv2.resize(trophy_crop, (0, 0), fx=2.0, fy=2.0, interpolation=cv2.INTER_CUBIC)
|
|
|
|
text = self.read_text(trophy_crop)
|
|
|
|
# Clean up text and parse
|
|
match = re.search(r"(\d+)\s*[/\\|]\s*(\d+)", text)
|
|
if match:
|
|
current_val = int(match.group(1))
|
|
max_val = int(match.group(2))
|
|
return current_val, max_val
|
|
|
|
return None
|
|
|
|
def read_crown_chest(self, frame: np.ndarray) -> Optional[Tuple[int, int]]:
|
|
"""
|
|
Extracts crown chest counter (current, max) from TOWER_LOBBY screen.
|
|
Expected format: '3/5' or '5/5' -> returns (current, 5).
|
|
Uses relative coordinates and dynamically scaled templates to support any resolution.
|
|
"""
|
|
fh, fw = frame.shape[:2]
|
|
if fh < 300 or fw < 500:
|
|
return None
|
|
|
|
scale = fh / 2050.0
|
|
|
|
# Relative ROI for crown digit badge
|
|
y1, y2 = int(fh * 0.6780), int(fh * 0.7146)
|
|
x1, x2 = int(fw * 0.2057), int(fw * 0.2214)
|
|
digit_roi = frame[y1:y2, x1:x2]
|
|
|
|
assets_dir = os.path.join(os.path.dirname(__file__), "assets")
|
|
|
|
# 1. Fast template check for 5/5 (critical trigger for opening crown chest)
|
|
tpl_5_path = os.path.join(assets_dir, "digit_crown_5.png")
|
|
if os.path.exists(tpl_5_path):
|
|
tpl_5 = cv2.imread(tpl_5_path)
|
|
if tpl_5 is not None:
|
|
if abs(scale - 1.0) > 0.02:
|
|
interp = cv2.INTER_AREA if scale < 1.0 else cv2.INTER_CUBIC
|
|
tpl_5 = cv2.resize(tpl_5, (0, 0), fx=scale, fy=scale, interpolation=interp)
|
|
if digit_roi.shape[0] >= tpl_5.shape[0] and digit_roi.shape[1] >= tpl_5.shape[1]:
|
|
res_5 = cv2.matchTemplate(digit_roi, tpl_5, cv2.TM_CCOEFF_NORMED)
|
|
_, max_v5, _, _ = cv2.minMaxLoc(res_5)
|
|
if max_v5 >= 0.85:
|
|
return 5, 5
|
|
|
|
# 2. Template check for 3/5
|
|
tpl_3_path = os.path.join(assets_dir, "digit_crown_3.png")
|
|
if os.path.exists(tpl_3_path):
|
|
tpl_3 = cv2.imread(tpl_3_path)
|
|
if tpl_3 is not None:
|
|
if abs(scale - 1.0) > 0.02:
|
|
interp = cv2.INTER_AREA if scale < 1.0 else cv2.INTER_CUBIC
|
|
tpl_3 = cv2.resize(tpl_3, (0, 0), fx=scale, fy=scale, interpolation=interp)
|
|
if digit_roi.shape[0] >= tpl_3.shape[0] and digit_roi.shape[1] >= tpl_3.shape[1]:
|
|
res_3 = cv2.matchTemplate(digit_roi, tpl_3, cv2.TM_CCOEFF_NORMED)
|
|
_, max_v3, _, _ = cv2.minMaxLoc(res_3)
|
|
if max_v3 >= 0.85:
|
|
return 3, 5
|
|
|
|
# 3. Fallback to Windows native OCR
|
|
by1, by2 = int(fh * 0.6732), int(fh * 0.7171)
|
|
bx1, bx2 = int(fw * 0.1927), int(fw * 0.2604)
|
|
badge_crop = frame[by1:by2, bx1:bx2]
|
|
w = badge_crop.shape[1]
|
|
text_part = badge_crop[:, :int(w * 0.65)]
|
|
up = cv2.resize(text_part, (0, 0), fx=3.0, fy=3.0, interpolation=cv2.INTER_CUBIC)
|
|
text = self.read_text(up)
|
|
|
|
match = re.search(r"(\d+)\s*[/\\|]\s*(\d+)", text)
|
|
if match:
|
|
current_val = int(match.group(1))
|
|
max_val = int(match.group(2))
|
|
return current_val, max_val
|
|
|
|
return None
|
|
|
|
def read_timer(self, timer_crop: np.ndarray) -> Optional[int]:
|
|
"""
|
|
Reads timer string (e.g. '51:53' or '1:12' or '01:23:45') and converts to total seconds.
|
|
"""
|
|
text = self.read_text(timer_crop)
|
|
parts = re.findall(r"\d+", text)
|
|
if len(parts) == 2:
|
|
minutes, seconds = int(parts[0]), int(parts[1])
|
|
return minutes * 60 + seconds
|
|
elif len(parts) == 3:
|
|
hours, minutes, seconds = int(parts[0]), int(parts[1]), int(parts[2])
|
|
return hours * 3600 + minutes * 60 + seconds
|
|
return None
|
|
|
|
|
|
# Global singleton instance for easy access
|
|
ocr_reader = OcrReader()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
test_img = cv2.imread("assets/trophy_numbers.png")
|
|
text = ocr_reader.read_text(test_img)
|
|
print("Raw OCR Text:", text)
|
|
|
|
full_frame = cv2.imread("assets/trophies_check_snapshot.png")
|
|
trophies = ocr_reader.read_trophies(full_frame)
|
|
print("Parsed Trophies as integers:", trophies)
|
|
if trophies:
|
|
current, maximum = trophies
|
|
print(f"Current: {current} (type: {type(current)}), Max: {maximum} (type: {type(maximum)})")
|
|
print(f"Trophies until goal: {maximum - current}")
|