gameboy_worlds.emulation.parser
1import warnings 2 3warnings.filterwarnings("ignore", message=".*SDL2 binaries.*") 4# To suppress pyBoy SDL2 warnings on some systems 5from pyboy import PyBoy 6from abc import ABC, abstractmethod 7from gameboy_worlds.utils import ( 8 log_error, 9 log_warn, 10 verify_parameters, 11 show_frames, 12 import_cv2, 13) 14 15 16import numpy as np 17 18import os 19from typing import Dict, Tuple, Optional, List, Union 20from PIL import Image 21 22 23def _get_proper_regions( 24 override_regions: List[Tuple[str, int, int, int, int]], 25 base_regions: List[Tuple[str, int, int, int, int]], 26) -> List[Tuple[str, int, int, int, int]]: 27 """ 28 Merges base regions with override regions, giving precedence to override regions. 29 30 :param override_regions: List of override region tuples. 31 :type override_regions: List[Tuple[str, int, int, int, int]] 32 :param base_regions: List of base region tuples. 33 :type base_regions: List[Tuple[str, int, int, int, int]] 34 :return: Merged list of region tuples. 35 :rtype: List[Tuple[str, int, int, int, int]] 36 """ 37 if len(override_regions) == 0: 38 return base_regions 39 proper_regions = override_regions.copy() 40 override_names = [region[0] for region in override_regions] 41 for region in base_regions: 42 if region[0] in override_names: 43 continue 44 proper_regions.append(region) 45 return proper_regions 46 47 48class NamedScreenRegion: 49 """ 50 Saves a reference to a named screen region (always a rectangle) for easy access. 51 """ 52 53 def __init__( 54 self, 55 name: str, 56 start_x: int, 57 start_y: int, 58 width: int, 59 height: int, 60 parameters: dict, 61 *, 62 target_path: Optional[str] = None, 63 multi_target_paths: Optional[Dict[str, str]] = None, 64 ): 65 """ 66 Initializes a named screen region. 67 68 Parameters: 69 name (str): The name of the screen region. 70 start_x (int): The starting x-coordinate of the region in pixel space of the full resolution game screen. 71 start_y (int): The starting y-coordinate of the region in pixel space of the full resolution game screen. 72 width (int): The width of the region in pixels. 73 height (int): The height of the region in pixels. 74 target (str): Optional path to a .npy file containing a screen capture of this region. Non-existent paths are only allowed if parameters['debug_mode'] (from configs/project_vars.yaml) is set to True. 75 multi_target_paths (Optional[Dict[str, str]]): Optional dictionary containing multiple possible paths to .npy files for this region. Keys are arbitrary strings, values are paths to .npy files. If provided, this will override target_path and force it None. Allows using the same region for multiple target images. 76 """ 77 if not isinstance(name, str): 78 log_error(f"name must be a string. Found {type(name)}", parameters) 79 if len(name.split()) > 1: 80 log_error( 81 f"name must be a single word with no spaces. Found {name}", parameters 82 ) 83 if "," in name: 84 log_error(f"name cannot contain commas. Found {name}", parameters) 85 self.name = name 86 """ Name of the screen region. """ 87 if ( 88 not isinstance(start_x, int) 89 or not isinstance(start_y, int) 90 or not isinstance(width, int) 91 or not isinstance(height, int) 92 ): 93 log_error( 94 f"start_x, start_y, width, and height must be integers. Found {type(start_x)}, {type(start_y)}, {type(width)}, {type(height)}", 95 parameters, 96 ) 97 self.start_x = start_x 98 """ The starting x-coordinate of the region. """ 99 self.start_y = start_y 100 """ The starting y-coordinate of the region. """ 101 self.width = width 102 """ The width of the region. """ 103 self.height = height 104 """ The height of the region. """ 105 self._parameters = parameters 106 self.target_path = None 107 """ Path to npy file of a screen capture that we will be comparing this region against. """ 108 self.target: Optional[np.ndarray] = None 109 """ Numpy array of the target image for this region. """ 110 self.multi_target_paths = multi_target_paths 111 self.multi_targets = None 112 """ Dictionary of multiple target paths for this region. """ 113 if multi_target_paths is not None: 114 self.multi_targets = {} 115 for key, path in multi_target_paths.items(): 116 if not isinstance(key, str): 117 log_error( 118 f"multi_target_paths keys must be strings. Found {type(key)}", 119 parameters, 120 ) 121 if len(key.split()) > 1: 122 log_error( 123 f"multi_target_paths keys must be single words with no spaces. Found {key}", 124 parameters, 125 ) 126 if "," in key: 127 log_error( 128 f"multi_target_paths keys cannot contain commas. Found {key}", 129 parameters, 130 ) 131 self.multi_targets[key] = self._sanity_load_target(path) 132 else: 133 if target_path is not None: 134 self.target_path = target_path 135 self.target = self._sanity_load_target(target_path) 136 137 def _sanity_load_target(self, target_path: str) -> Optional[np.ndarray]: 138 """ 139 Loads the target image from the given path. 140 141 :param target_path: Path to the .npy file containing the target image. 142 :type target_path: str 143 :return: The loaded target image as a numpy array, or None if the file does not exist and debug_mode is enabled. 144 :rtype: ndarray[_AnyShape, dtype[Any]] | None 145 """ 146 if not target_path.endswith(".npy"): 147 target_path = target_path + ".npy" 148 if not os.path.exists(target_path): 149 if not self._parameters["debug_mode"]: 150 log_error( 151 f"Target file {target_path} does not exist. This is only allowed in debug_mode (can be set in configs/project_vars.yaml)", 152 self._parameters, 153 ) 154 else: 155 log_warn( 156 f"Target file {target_path} does not exist. Continuing since debug_mode is enabled.", 157 self._parameters, 158 ) 159 return None 160 else: 161 target = np.load(target_path) 162 return target 163 164 def get_end_x(self) -> int: 165 """ 166 Returns the end x-coordinate of the named screen region. 167 168 Returns: 169 int: The end x-coordinate of the named screen region. 170 """ 171 return self.start_x + self.width 172 173 def get_end_y(self) -> int: 174 """ 175 Returns the end y-coordinate of the named screen region. 176 Returns: 177 int: The end y-coordinate of the named screen region. 178 """ 179 return self.start_y + self.height 180 181 def get_corners(self) -> Tuple[int, int, int, int]: 182 """ 183 Returns the corners of the named screen region as (start_x, start_y, end_x, end_y). 184 185 :return: The corners of the named screen region. 186 :rtype: Tuple[int, int, int, int] 187 """ 188 return (self.start_x, self.start_y, self.get_end_x(), self.get_end_y()) 189 190 def __str__(self) -> str: 191 return f"NamedScreenRegion(name={self.name}, start_x={self.start_x}, start_y={self.start_y}, width={self.width}, height={self.height})" 192 193 def __repr__(self) -> str: 194 return self.__str__() 195 196 def compare_against_target( 197 self, reference: np.ndarray, strict_shape: bool = True 198 ) -> float: 199 """ 200 Computes the Absolute Error (AE) between the given reference image and the target image. 201 202 :param reference: The reference image to compare. 203 :type reference: np.ndarray 204 :param strict_shape: Whether to error out if the array shapes do not match. 205 :type strict_shape: bool 206 :return: The Absolute Error (AE) between the reference and target images. 207 :rtype: float 208 """ 209 if self.target is None: 210 if self._parameters["debug_mode"]: 211 return float("inf") 212 log_error( 213 f"No target image set for NamedScreenRegion {self.name}. Cannot compare.", 214 self._parameters, 215 ) 216 if reference.shape != self.target.shape: 217 if strict_shape: 218 log_error( 219 f"Reference image shape {reference.shape} does not match target image shape {self.target.shape} for NamedScreenRegion {self.name}.", 220 self._parameters, 221 ) 222 else: 223 return float("inf") 224 diff = np.abs(reference.astype(np.float32) - self.target.astype(np.float32)) 225 mae = np.mean(diff) 226 return mae 227 228 def compare_against_multi_target( 229 self, target_name: str, reference: np.ndarray, strict_shape: bool = True 230 ) -> float: 231 """ 232 Computes the Absolute Error (AE) between the given reference image and one of the multiple target images. 233 234 :param self: Description 235 :param target_name: The name of the target image to compare against. 236 :type target_name: str 237 :param reference: The reference image to compare. 238 :type reference: np.ndarray 239 :param strict_shape: Whether to error out if the array shapes do not match. 240 :type strict_shape: bool 241 :return: The Absolute Error (AE) between the reference and specified target images. 242 :rtype: float 243 """ 244 if self.multi_targets is None or target_name not in self.multi_targets: 245 log_error( 246 f"No multi target image set for NamedScreenRegion {self.name} with target name {target_name}. Cannot compare.", 247 self._parameters, 248 ) 249 self.target = self.multi_targets[target_name] 250 mae = self.compare_against_target(reference, strict_shape) 251 self.target = None 252 return mae 253 254 def matches_target( 255 self, reference: np.ndarray, strict_shape: bool = True, epsilon=0.01 256 ) -> bool: 257 """ 258 Compares the given reference image to the target image using Absolute Error (AE). 259 260 :param self: Description 261 :param reference: The reference image to compare. 262 :type reference: np.ndarray 263 :param strict_shape: Whether to error out if the array shapes do not match. 264 :type strict_shape: bool 265 :param epsilon: The threshold for considering a match. 266 :return: True if the AE is below the epsilon threshold, False otherwise. 267 :rtype: bool 268 """ 269 mae = self.compare_against_target(reference, strict_shape) 270 if mae <= epsilon: 271 return True 272 return False 273 274 def matches_multi_target( 275 self, target_name: str, reference: np.ndarray, strict_shape: bool = True 276 ) -> bool: 277 """ 278 Compares the given reference image to one of the multiple target images using Absolute Error (AE). 279 280 :param target_name: The name of the target image to compare against. 281 :type target_name: str 282 :param reference: The reference image to compare. 283 :type reference: np.ndarray 284 :param strict_shape: Whether to error out if the array shapes do not match. 285 :type strict_shape: bool 286 :return: True if the AE is below the epsilon threshold, False otherwise. 287 :rtype: bool 288 """ 289 if self.multi_targets is None or target_name not in self.multi_targets: 290 log_error( 291 f"No multi target image set for NamedScreenRegion {self.name} with target name {target_name}. Cannot compare.", 292 self._parameters, 293 ) 294 self.target = self.multi_targets[target_name] 295 result = self.matches_target(reference, strict_shape) 296 self.target = None 297 return result 298 299 def matches_any_multi_target( 300 self, target_names: List[str], reference: np.ndarray, strict_shape: bool = True 301 ) -> bool: 302 """ 303 Compares the given reference image to all of the specified target images and returns True if any of them match. 304 305 :param target_names: The names of the target images to compare against. 306 :type target_names: List[str] 307 :param reference: The reference image to compare. 308 :type reference: np.ndarray 309 :param strict_shape: Whether to error out if the array shapes do not match. 310 :type strict_shape: bool 311 :return: True if the AE is below the epsilon threshold for any of the target images, False otherwise. 312 :rtype: bool 313 """ 314 for target_name in target_names: 315 if self.matches_multi_target(target_name, reference, strict_shape): 316 return True 317 return False 318 319 320class StateParser(ABC): 321 """ 322 Abstract base class for parsing game state variables from the GameBoy emulator. 323 """ 324 325 def __init__( 326 self, 327 pyboy, 328 parameters, 329 named_screen_regions: Optional[List[NamedScreenRegion]] = None, 330 ): 331 """ 332 Initializes the StateParser. Child implementations should call super().__init__() after running their code. 333 All children must create a self.rom_data_path variable 334 Args: 335 pyboy: An instance of the PyBoy emulator. 336 parameters: A dictionary of parameters for configuration. 337 named_screen_regions (Optional[list[NamedScreenRegion]]): A list of NamedScreenRegion objects for easy access to specific screen regions. 338 """ 339 verify_parameters(parameters) 340 self._parameters = parameters 341 if not hasattr(self, "rom_data_path"): 342 log_error( 343 f"StateParsers must define a self.rom_data_path variable pointing to the rom data path for the game variant.", 344 self._parameters, 345 ) 346 self.rom_data_path: str = self.rom_data_path 347 """ Path to the rom data directory for the game variant. """ 348 if not isinstance(pyboy, PyBoy): 349 log_error("pyboy must be an instance of PyBoy", self._parameters) 350 self._pyboy = pyboy 351 self.named_screen_regions: dict[str, NamedScreenRegion] = {} 352 """ Dictionary of NamedScreenRegion objects for easy access to specific screen regions. """ 353 if named_screen_regions is not None: 354 for region in named_screen_regions: 355 if not isinstance(region, NamedScreenRegion): 356 log_error( 357 f"named_screen_regions must be a list of NamedScreenRegion objects. Found {type(region)}", 358 self._parameters, 359 ) 360 if region.name in self.named_screen_regions: 361 log_error( 362 f"Duplicate named screen region: {region.name}", 363 self._parameters, 364 ) 365 self.named_screen_regions[region.name] = region 366 self.image_references = {} 367 """ Dictionary of image references loaded. """ 368 location = os.path.join(self.rom_data_path, "image_references") 369 if os.path.exists(location): 370 for file in os.listdir(location): 371 image_path = os.path.join(location, file) 372 if image_path.endswith((".png", ".jpg", ".jpeg")): 373 reference_name = file.rsplit(".", 1)[0] 374 image = Image.open(image_path) 375 self.image_references[reference_name] = image 376 else: 377 log_warn( 378 f"Found unsupported image extension {file} in {location}. Only place image files in this folder.", 379 self._parameters, 380 ) 381 382 @staticmethod 383 def bit_count(bits: int) -> int: 384 """ 385 Counts the number of set bits (1s) in the given integer. 386 Args: 387 bits (int): The integer to count set bits in. 388 Returns: 389 int: The number of set bits. 390 """ 391 return bin(bits).count("1") 392 393 def read_m(self, addr: bytes) -> int: 394 """ 395 Reads a byte from the specified memory address. 396 Args: 397 addr (int): The memory address to read from. 398 Returns: 399 int: The byte value at the specified memory address. 400 """ 401 # return self.pyboy.get_memory_value(addr) 402 return self._pyboy.memory[addr] 403 404 def read_bits(self, addr) -> str: 405 """ 406 Reads a memory address and returns the result as a binary string. Adds padding so that reading bit 0 works correctly. 407 Args: 408 addr (int): The memory address to read from. 409 Returns: 410 str: The binary string representation of the byte at the specified memory address. 411 """ 412 # add padding so zero will read '0b100000000' instead of '0b0' 413 return bin(256 + self.read_m(addr)) 414 415 def read_bit(self, addr, bit: int) -> bool: 416 """ 417 Reads a specific bit from a memory address. 418 Args: 419 addr (int): The memory address to read from. 420 bit (int): The bit position to read (0-7). 421 Returns: 422 bool: True if the bit is set (1), False otherwise. 423 """ 424 # add padding so zero will read '0b100000000' instead of '0b0' 425 return self.read_bits(addr)[-bit - 1] == "1" 426 427 def read_m_bit(self, addr_bit: str) -> bool: 428 """ 429 Reads a specific addr-bit string from a memory address. 430 Args: 431 addr_bit (str): The - concatenation of a memory address and the bit position (e.g. '0xD87D-5') 432 Returns: 433 bool: True if the bit at that memory address is set (1), False otherwise 434 """ 435 if "-" not in addr_bit: 436 log_error(f"Incorrect format addr_bit: {addr_bit}", self._parameters) 437 addr, bit = addr_bit.split("-") 438 flag = False 439 try: 440 addr = eval(addr) 441 except: 442 flag = True 443 if flag: 444 log_error( 445 f"Could not eval byte string: {addr}. Check format", self._parameters 446 ) 447 if not bit.isdigit(): 448 log_error(f"bit {bit} is not digit", self._parameters) 449 bit = int(bit) 450 return self.read_bit(addr, bit) 451 452 def get_raised_flags(self, item_dict: dict) -> set: 453 """ 454 Reads a dictionary of the form {flag_name: memory_address-bit} and returns a set of all flag names that are currently raised (i.e. the bit at the memory address is 1). 455 Args: 456 item_dict (dict): A dictionary mapping flag names to memory address-bit strings. 457 Returns: 458 set: A set of flag names that are currently raised. 459 """ 460 items = set() 461 for item_name, slot in item_dict.items(): 462 if self.read_m_bit(slot): 463 items.add(item_name) 464 return items 465 466 def get_current_frame(self) -> np.ndarray: 467 """ 468 Reads the pyboy screen and returns a full resolution numpy array 469 470 Returns: 471 np.ndarray: The rendered image as a numpy array. 472 """ 473 screen = self._pyboy.screen.ndarray[ 474 :, :, 0:1 475 ] # (144, 160, 3) but force just greyscale 476 return screen.copy() 477 478 @staticmethod 479 def capture_box( 480 current_frame: np.ndarray, 481 start_x: int, 482 start_y: int, 483 width: int, 484 height: int, 485 ) -> np.ndarray: 486 """ 487 Captures a rectangular region from the current frame. 488 489 Args: 490 current_frame (np.ndarray): The current frame from the emulator. 491 start_x (int): The starting x-coordinate of the region. 492 start_y (int): The starting y-coordinate of the region. 493 width (int): The width of the region. 494 height (int): The height of the region. 495 Returns: 496 np.ndarray: The captured rectangular region. 497 """ 498 # first check that the box is within the frame 499 end_x = start_x + width 500 end_y = start_y + height 501 if ( 502 start_x < 0 503 or start_y < 0 504 or end_x > current_frame.shape[1] 505 or end_y > current_frame.shape[0] 506 ): 507 start_x = max(0, start_x) 508 start_y = max(0, start_y) 509 end_x = min(current_frame.shape[1], end_x) 510 end_y = min(current_frame.shape[0], end_y) 511 return current_frame[start_y:end_y, start_x:end_x, :] 512 513 @staticmethod 514 def capture_square_centered( 515 current_frame: np.ndarray, center_x: int, center_y: int, box_size: int 516 ) -> np.ndarray: 517 """ 518 Captures a square region from the current frame centered at (center_x, center_y) with the given box size. 519 520 Args: 521 current_frame (np.ndarray): The current frame from the emulator. 522 center_x (int): The x-coordinate of the center of the square. 523 center_y (int): The y-coordinate of the center of the square. 524 box_size (int): The size of the square box to capture. 525 526 Returns: 527 np.ndarray: The captured square region. 528 """ 529 half_box = box_size // 2 530 start_x = max(center_x - half_box, 0) 531 end_x = min(center_x + half_box, current_frame.shape[1]) 532 start_y = max(center_y - half_box, 0) 533 end_y = min(center_y + half_box, current_frame.shape[0]) 534 return current_frame[start_y:end_y, start_x:end_x, :] 535 536 @staticmethod 537 def draw_box( 538 current_frame: np.ndarray, 539 start_x: int, 540 start_y: int, 541 width: int, 542 height: int, 543 color: tuple = (0, 0, 0), 544 thickness: int = 1, 545 ) -> np.ndarray: 546 """ 547 Draws a rectangle on the current frame. 548 549 Args: 550 current_frame (np.ndarray): The current frame from the emulator. 551 start_x (int): The starting x-coordinate of the rectangle. 552 start_y (int): The starting y-coordinate of the rectangle. 553 width (int): The width of the rectangle. 554 height (int): The height of the rectangle. 555 color (tuple, optional): The color of the rectangle in BGR format. 556 thickness (int, optional): The thickness of the rectangle border. 557 558 Returns: 559 np.ndarray: The frame with the drawn rectangle. 560 """ 561 end_x = start_x + width 562 end_y = start_y + height 563 if ( 564 start_x < 0 565 or start_y < 0 566 or end_x > current_frame.shape[1] 567 or end_y > current_frame.shape[0] 568 ): 569 start_x = max(0, start_x) 570 start_y = max(0, start_y) 571 end_x = min(current_frame.shape[1], end_x) 572 end_y = min(current_frame.shape[0], end_y) 573 frame_with_box = current_frame.copy() 574 cv2 = import_cv2(None) 575 cv2.rectangle( 576 frame_with_box, (start_x, start_y), (end_x, end_y), color, thickness 577 ) 578 return frame_with_box 579 580 @staticmethod 581 def draw_square_centered( 582 current_frame: np.ndarray, 583 center_x: int, 584 center_y: int, 585 box_size: int, 586 color: tuple = (0, 0, 0), 587 thickness: int = 1, 588 ) -> np.ndarray: 589 """ 590 Draws a square on the current frame centered at (center_x, center_y) with the given box size. 591 592 Args: 593 current_frame (np.ndarray): The current frame from the emulator. 594 center_x (int): The x-coordinate of the center of the square. 595 center_y (int): The y-coordinate of the center of the square. 596 box_size (int): The size of the square box to draw. 597 color (tuple, optional): The color of the square in BGR format. 598 thickness (int, optional): The thickness of the square border. 599 600 Returns: 601 np.ndarray: The frame with the drawn square. 602 """ 603 half_box = box_size // 2 604 start_x = max(center_x - half_box, 0) 605 end_x = min(center_x + half_box, current_frame.shape[1]) 606 start_y = max(center_y - half_box, 0) 607 end_y = min(center_y + half_box, current_frame.shape[0]) 608 frame_with_square = current_frame.copy() 609 cv2 = import_cv2(None) 610 cv2.rectangle( 611 frame_with_square, (start_x, start_y), (end_x, end_y), color, thickness 612 ) 613 return frame_with_square 614 615 def capture_named_region(self, current_frame: np.ndarray, name: str) -> np.ndarray: 616 """ 617 Captures a named region from the current frame. 618 619 Args: 620 current_frame (np.ndarray): The current frame from the emulator. 621 name (str): The name of the region to capture. 622 623 Returns: 624 np.ndarray: The captured region. 625 """ 626 if name not in self.named_screen_regions: 627 log_error(f"Named screen region {name} not found.", self._parameters) 628 region = self.named_screen_regions[name] 629 x, y, w, h = region.start_x, region.start_y, region.width, region.height 630 return self.capture_box(current_frame, x, y, w, h) 631 632 def compare_named_region_against_target( 633 self, current_frame: np.ndarray, name: str, strict_shape: bool = True 634 ) -> float: 635 """ 636 Computes the Absolute Error (AE) between a named region from the current frame and its target image. 637 638 Args: 639 current_frame (np.ndarray): The current frame from the emulator. 640 name (str): The name of the region to compare. 641 strict_shape (bool, optional): Whether to error out if the array shapes do not match. 642 Returns: 643 float: The Absolute Error (AE) between the named region and its target image. 644 """ 645 if name not in self.named_screen_regions: 646 log_error(f"Named screen region {name} not found.", self._parameters) 647 region = self.named_screen_regions[name] 648 captured_region = self.capture_named_region(current_frame, name) 649 return region.compare_against_target(captured_region, strict_shape) 650 651 def named_region_matches_target(self, current_frame: np.ndarray, name: str) -> bool: 652 """ 653 Compares a named region from the current frame to its target image using Absolute Error (AE). 654 655 Args: 656 current_frame (np.ndarray): The current frame from the emulator. 657 name (str): The name of the region to compare. 658 Returns: 659 bool: True if the region matches the target image, False otherwise. 660 """ 661 if name not in self.named_screen_regions: 662 log_error(f"Named screen region {name} not found.", self._parameters) 663 region = self.named_screen_regions[name] 664 captured_region = self.capture_named_region(current_frame, name) 665 return region.matches_target(captured_region) 666 667 def compare_named_region_against_multi_target( 668 self, 669 current_frame: np.ndarray, 670 name: str, 671 target_name: str, 672 strict_shape: bool = True, 673 ) -> float: 674 """ 675 Computes the Absolute Error (AE) between a named region from the current frame and one of its multiple target images. 676 677 Args: 678 current_frame (np.ndarray): The current frame from the emulator. 679 name (str): The name of the region to compare. 680 target_name (str): The name of the target image to compare against. 681 strict_shape (bool, optional): Whether to error out if the array shapes do not match. 682 Returns: 683 float: The Absolute Error (AE) between the named region and the specified target image. 684 """ 685 if name not in self.named_screen_regions: 686 log_error(f"Named screen region {name} not found.", self._parameters) 687 region = self.named_screen_regions[name] 688 captured_region = self.capture_named_region(current_frame, name) 689 return region.compare_against_multi_target( 690 target_name, captured_region, strict_shape 691 ) 692 693 def named_region_matches_multi_target( 694 self, current_frame: np.ndarray, name: str, target_name: str 695 ) -> bool: 696 """ 697 Compares a named region from the current frame to one of its multiple target images using Absolute Error (AE). 698 699 Args: 700 current_frame (np.ndarray): The current frame from the emulator. 701 name (str): The name of the region to compare. 702 target_name (str): The name of the target image to compare against. 703 Returns: 704 bool: True if the region matches the specified target image, False otherwise. 705 """ 706 if name not in self.named_screen_regions: 707 log_error(f"Named screen region {name} not found.", self._parameters) 708 region = self.named_screen_regions[name] 709 captured_region = self.capture_named_region(current_frame, name) 710 return region.matches_multi_target(target_name, captured_region) 711 712 def draw_named_region( 713 self, 714 current_frame: np.ndarray, 715 name: str, 716 color: tuple = (0, 0, 0), 717 thickness: int = 1, 718 ) -> np.ndarray: 719 """ 720 Draws a named region on the current frame. 721 722 Args: 723 current_frame (np.ndarray): The current frame from the emulator. 724 name (str): The name of the region to draw. 725 color (tuple, optional): The color of the rectangle in BGR format. 726 thickness (int, optional): The thickness of the rectangle border. 727 728 Returns: 729 np.ndarray: The frame with the drawn rectangle. 730 """ 731 if name not in self.named_screen_regions: 732 log_error(f"Named screen region {name} not found.", self._parameters) 733 region = self.named_screen_regions[name] 734 x, y, w, h = region.start_x, region.start_y, region.width, region.height 735 return self.draw_box(current_frame, x, y, w, h, color, thickness) 736 737 @staticmethod 738 def draw_grid_overlay( 739 current_frame: np.ndarray, grid_skip: int = 16, x_offset=0, y_offset=-2 740 ) -> np.ndarray: 741 """ 742 Draws a grid overlay on the current frame for easier region identification. 743 Args: 744 current_frame (np.ndarray): The current frame from the emulator. 745 grid_skip (int, optional): The number of pixels between grid lines. 746 x_offset (int, optional): The x-offset to apply when drawing the grid. 747 y_offset (int, optional): The y-offset to apply when drawing the grid. 748 Returns: 749 np.ndarray: The frame with the grid overlay. 750 """ 751 frame_with_grid = current_frame.copy() 752 cv2 = import_cv2(None) 753 for x in range(0, current_frame.shape[1], grid_skip): 754 cv2.line( 755 frame_with_grid, 756 (x + x_offset, 0), 757 (x + x_offset, current_frame.shape[0]), 758 (0, 0, 255), 759 1, 760 lineType=cv2.LINE_AA, 761 ) 762 for y in range(0, current_frame.shape[0], grid_skip): 763 cv2.line( 764 frame_with_grid, 765 (0, y + y_offset), 766 (current_frame.shape[1], y + y_offset), 767 (0, 0, 255), 768 1, 769 lineType=cv2.LINE_AA, 770 ) 771 return frame_with_grid 772 773 @staticmethod 774 def capture_grid_cells( 775 current_frame: np.ndarray, 776 *, 777 quadrant: str = None, 778 grid_skip: int = 16, 779 x_offset=0, 780 y_offset=-2, 781 ) -> Dict[Tuple[int, int], np.ndarray]: 782 """ 783 Captures all grid cells from the current frame based on the specified grid skip. 784 785 Example: 786 ```python 787 import matplotlib.pyplot as plt 788 # ... run the state_parser in an env, example in dev_play. 789 grid_cells = StateParser.capture_grid_cells(current_frame) 790 keep_keys = [(0, 0), (0, 1)] 791 new_cells = {} 792 for cell in keep_keys: 793 new_cells[cell] = grid_cells[cell] 794 grid_cells = new_cells 795 drawn_frame = self.state_parser.reform_image(grid_cells) 796 quadrants = self.state_parser.get_quadrant_frame(grid_cells=grid_cells) 797 plt.imshow(drawn_frame[:, :, 0], cmap="gray") 798 plt.title(f"Full Screen with Grid Overlay") 799 plt.show() 800 merged = self.state_parser.reform_image(grid_cells) 801 plt.imshow(merged[:, :, 0], cmap="gray") 802 plt.show() 803 ``` 804 805 :param current_frame: An emulator frame. 806 :type current_frame: np.ndarray 807 :param quadrant: If specified, only captures cells in the given quadrant ('TL', 'TR', 'BL', 'BR'). 808 :type quadrant: str 809 :param grid_skip: The number of pixels between grid lines. 810 :type grid_skip: int 811 :param x_offset: The x-offset to apply when capturing cells. 812 :param y_offset: The y-offset to apply when capturing cells. 813 :return: A dictionary mapping grid cell coordinates to their captured images. 814 The grid cells are with the central cell as (0,0) 815 :rtype: Dict[Tuple[int, int], ndarray[_AnyShape, dtype[Any]]] 816 """ 817 if quadrant is not None: 818 if quadrant.lower() not in ["tl", "tr", "bl", "br"]: 819 log_error( 820 f"Invalid quadrant: {quadrant}. Must be one of 'TL', 'TR', 'BL', 'BR'", 821 ) 822 cells = {} 823 if x_offset != 0: 824 x_iter = [-x_offset] + list(range(0, current_frame.shape[1], grid_skip)) 825 else: 826 x_iter = list(range(0, current_frame.shape[1], grid_skip)) 827 if y_offset != 0: 828 y_iter = [-y_offset] + list(range(0, current_frame.shape[0], grid_skip)) 829 else: 830 y_iter = list(range(0, current_frame.shape[0], grid_skip)) 831 832 def x_ind(x): 833 index = x_iter.index(x) 834 return (index - (len(x_iter)) // 2) + 1 * (x_offset == 0) 835 836 def y_ind(y): 837 index = y_iter.index(y) 838 return -(index - len(y_iter) // 2) + 1 * (y_offset == 0) 839 840 for x in x_iter: 841 for y in y_iter: 842 x_cell = x_ind(x) 843 y_cell = y_ind(y) 844 if quadrant is not None: 845 if quadrant.lower() == "tl" and (x_cell > 0 or y_cell < 0): 846 continue 847 elif quadrant.lower() == "tr" and (x_cell < 0 or y_cell < 0): 848 continue 849 elif quadrant.lower() == "bl" and (x_cell > 0 or y_cell > 0): 850 continue 851 elif quadrant.lower() == "br" and (x_cell < 0 or y_cell > 0): 852 continue 853 cell_image = StateParser.capture_box( 854 current_frame, x + x_offset, y + y_offset, grid_skip, grid_skip 855 ) 856 cells[(x_cell, y_cell)] = cell_image 857 return cells 858 859 @staticmethod 860 def reform_image(grid_cells: Dict[Tuple[int, int], np.ndarray]) -> np.ndarray: 861 """ 862 Reform the image from grid cells back into a single image. 863 Expects the grid_cells to correspond to a rectangle. 864 Args: 865 grid_cells (Dict[Tuple[int, int], np.ndarray]): A dictionary mapping (x, y) coordinates to image cells. 866 867 Returns: 868 np.ndarray: The reformed image. 869 """ 870 coords = grid_cells.keys() 871 if len(coords) == 1: 872 return list(grid_cells.values())[0] 873 xs = list(set([coord[0] for coord in coords])) 874 ys = list(set([coord[1] for coord in coords])) 875 xs.sort() 876 ys.sort() 877 rows = [] 878 for y in ys: 879 row_cells = [] 880 for x in xs: 881 row_cells.append(grid_cells[(x, y)]) 882 row_image = np.concatenate(row_cells, axis=1) 883 rows.append(row_image) 884 new_rows = [] 885 886 if len(rows) == 1: 887 new_rows.append(rows[0]) 888 else: 889 # This part is super hacky. 890 # Sometimes, the last and second last row are the exact same. In that case, skip the last row. I don't know man. 891 # show_frames(rows) 892 is_same = rows[-1].shape != rows[-2].shape 893 back_offset = 2 if is_same else 1 894 for item in range(len(rows) - back_offset, -1, -1): 895 new_rows.append(rows[item]) 896 full_image = np.concatenate(new_rows, axis=0) 897 return full_image 898 899 def get_quadrant_frame( 900 self, grid_cells: Dict[Tuple[int, int], np.ndarray] = None 901 ) -> Dict[str, Dict[str, Union[np.ndarray, Dict[Tuple[int, int], np.ndarray]]]]: 902 """ 903 Divides the current frame or subframe into quadrants and returns groups of quadrants 904 905 :param grid_cells: Subset of grid cells to split. Must be a rectangular box in (x, y) space. 906 :type grid_cells: Dict[Tuple[int, int], np.ndarray] 907 :return: A dictionary where the keys are quadrant keys [tr, tl, br, bl] and values are: 908 909 - screen: which maps to the single numpy array representing that quadrant as a screen 910 - cells: A dictionary mapping cell grids to the specific screen region as numpy arrays. 911 :rtype: Dict[str, Dict[str, Union[np.ndarray, Dict[Tuple[int, int], np.ndarray]]]] 912 """ 913 if grid_cells is None: 914 grid_cells = self.capture_grid_cells(self.get_current_frame()) 915 coords = grid_cells.keys() 916 xs = list(set([coord[0] for coord in coords])) 917 ys = list(set([coord[1] for coord in coords])) 918 xs.sort() 919 ys.sort() 920 mid_x = xs[len(xs) // 2] 921 mid_y = ys[len(ys) // 2] 922 quadrants = { 923 "tl": {"screen": None, "cells": {}}, 924 "tr": {"screen": None, "cells": {}}, 925 "bl": {"screen": None, "cells": {}}, 926 "br": {"screen": None, "cells": {}}, 927 } 928 lower_x = [x for x in xs if x < mid_x] 929 higher_x = [x for x in xs if x >= mid_x] 930 lower_y = [y for y in ys if y < mid_y] 931 higher_y = [y for y in ys if y >= mid_y] 932 for x in lower_x: 933 for y in higher_y: 934 quadrants["tl"]["cells"][(x, y)] = grid_cells[(x, y)] 935 quadrants["tl"]["screen"] = self.reform_image(quadrants["tl"]["cells"]) 936 for x in higher_x: 937 for y in higher_y: 938 quadrants["tr"]["cells"][(x, y)] = grid_cells[(x, y)] 939 quadrants["tr"]["screen"] = self.reform_image(quadrants["tr"]["cells"]) 940 for x in lower_x: 941 for y in lower_y: 942 quadrants["bl"]["cells"][(x, y)] = grid_cells[(x, y)] 943 quadrants["bl"]["screen"] = self.reform_image(quadrants["bl"]["cells"]) 944 for x in higher_x: 945 for y in lower_y: 946 quadrants["br"]["cells"][(x, y)] = grid_cells[(x, y)] 947 quadrants["br"]["screen"] = self.reform_image(quadrants["br"]["cells"]) 948 return quadrants 949 950 def get_image_reference(self, reference_name: str) -> Image.Image: 951 """ 952 Gets an image reference from the loaded image references. 953 Args: 954 reference_name (str): The name of the image reference to load 955 Returns: 956 Image.Image: The loaded image reference. 957 """ 958 if reference_name not in self.image_references: 959 log_error( 960 f"Image reference {reference_name} not found. Available options: {self.image_references.keys()}. If you want to add an image reference, add a file to the image_references folder.", 961 self._parameters, 962 ) 963 return self.image_references[reference_name] 964 965 @abstractmethod 966 def __repr__(self) -> str: 967 """ 968 Name of the parser for logging purposes. 969 :return: string name of the parser 970 """ 971 raise NotImplementedError 972 973 974class DummyParser(StateParser): 975 def __repr__(self) -> str: 976 return "DummyParser"
49class NamedScreenRegion: 50 """ 51 Saves a reference to a named screen region (always a rectangle) for easy access. 52 """ 53 54 def __init__( 55 self, 56 name: str, 57 start_x: int, 58 start_y: int, 59 width: int, 60 height: int, 61 parameters: dict, 62 *, 63 target_path: Optional[str] = None, 64 multi_target_paths: Optional[Dict[str, str]] = None, 65 ): 66 """ 67 Initializes a named screen region. 68 69 Parameters: 70 name (str): The name of the screen region. 71 start_x (int): The starting x-coordinate of the region in pixel space of the full resolution game screen. 72 start_y (int): The starting y-coordinate of the region in pixel space of the full resolution game screen. 73 width (int): The width of the region in pixels. 74 height (int): The height of the region in pixels. 75 target (str): Optional path to a .npy file containing a screen capture of this region. Non-existent paths are only allowed if parameters['debug_mode'] (from configs/project_vars.yaml) is set to True. 76 multi_target_paths (Optional[Dict[str, str]]): Optional dictionary containing multiple possible paths to .npy files for this region. Keys are arbitrary strings, values are paths to .npy files. If provided, this will override target_path and force it None. Allows using the same region for multiple target images. 77 """ 78 if not isinstance(name, str): 79 log_error(f"name must be a string. Found {type(name)}", parameters) 80 if len(name.split()) > 1: 81 log_error( 82 f"name must be a single word with no spaces. Found {name}", parameters 83 ) 84 if "," in name: 85 log_error(f"name cannot contain commas. Found {name}", parameters) 86 self.name = name 87 """ Name of the screen region. """ 88 if ( 89 not isinstance(start_x, int) 90 or not isinstance(start_y, int) 91 or not isinstance(width, int) 92 or not isinstance(height, int) 93 ): 94 log_error( 95 f"start_x, start_y, width, and height must be integers. Found {type(start_x)}, {type(start_y)}, {type(width)}, {type(height)}", 96 parameters, 97 ) 98 self.start_x = start_x 99 """ The starting x-coordinate of the region. """ 100 self.start_y = start_y 101 """ The starting y-coordinate of the region. """ 102 self.width = width 103 """ The width of the region. """ 104 self.height = height 105 """ The height of the region. """ 106 self._parameters = parameters 107 self.target_path = None 108 """ Path to npy file of a screen capture that we will be comparing this region against. """ 109 self.target: Optional[np.ndarray] = None 110 """ Numpy array of the target image for this region. """ 111 self.multi_target_paths = multi_target_paths 112 self.multi_targets = None 113 """ Dictionary of multiple target paths for this region. """ 114 if multi_target_paths is not None: 115 self.multi_targets = {} 116 for key, path in multi_target_paths.items(): 117 if not isinstance(key, str): 118 log_error( 119 f"multi_target_paths keys must be strings. Found {type(key)}", 120 parameters, 121 ) 122 if len(key.split()) > 1: 123 log_error( 124 f"multi_target_paths keys must be single words with no spaces. Found {key}", 125 parameters, 126 ) 127 if "," in key: 128 log_error( 129 f"multi_target_paths keys cannot contain commas. Found {key}", 130 parameters, 131 ) 132 self.multi_targets[key] = self._sanity_load_target(path) 133 else: 134 if target_path is not None: 135 self.target_path = target_path 136 self.target = self._sanity_load_target(target_path) 137 138 def _sanity_load_target(self, target_path: str) -> Optional[np.ndarray]: 139 """ 140 Loads the target image from the given path. 141 142 :param target_path: Path to the .npy file containing the target image. 143 :type target_path: str 144 :return: The loaded target image as a numpy array, or None if the file does not exist and debug_mode is enabled. 145 :rtype: ndarray[_AnyShape, dtype[Any]] | None 146 """ 147 if not target_path.endswith(".npy"): 148 target_path = target_path + ".npy" 149 if not os.path.exists(target_path): 150 if not self._parameters["debug_mode"]: 151 log_error( 152 f"Target file {target_path} does not exist. This is only allowed in debug_mode (can be set in configs/project_vars.yaml)", 153 self._parameters, 154 ) 155 else: 156 log_warn( 157 f"Target file {target_path} does not exist. Continuing since debug_mode is enabled.", 158 self._parameters, 159 ) 160 return None 161 else: 162 target = np.load(target_path) 163 return target 164 165 def get_end_x(self) -> int: 166 """ 167 Returns the end x-coordinate of the named screen region. 168 169 Returns: 170 int: The end x-coordinate of the named screen region. 171 """ 172 return self.start_x + self.width 173 174 def get_end_y(self) -> int: 175 """ 176 Returns the end y-coordinate of the named screen region. 177 Returns: 178 int: The end y-coordinate of the named screen region. 179 """ 180 return self.start_y + self.height 181 182 def get_corners(self) -> Tuple[int, int, int, int]: 183 """ 184 Returns the corners of the named screen region as (start_x, start_y, end_x, end_y). 185 186 :return: The corners of the named screen region. 187 :rtype: Tuple[int, int, int, int] 188 """ 189 return (self.start_x, self.start_y, self.get_end_x(), self.get_end_y()) 190 191 def __str__(self) -> str: 192 return f"NamedScreenRegion(name={self.name}, start_x={self.start_x}, start_y={self.start_y}, width={self.width}, height={self.height})" 193 194 def __repr__(self) -> str: 195 return self.__str__() 196 197 def compare_against_target( 198 self, reference: np.ndarray, strict_shape: bool = True 199 ) -> float: 200 """ 201 Computes the Absolute Error (AE) between the given reference image and the target image. 202 203 :param reference: The reference image to compare. 204 :type reference: np.ndarray 205 :param strict_shape: Whether to error out if the array shapes do not match. 206 :type strict_shape: bool 207 :return: The Absolute Error (AE) between the reference and target images. 208 :rtype: float 209 """ 210 if self.target is None: 211 if self._parameters["debug_mode"]: 212 return float("inf") 213 log_error( 214 f"No target image set for NamedScreenRegion {self.name}. Cannot compare.", 215 self._parameters, 216 ) 217 if reference.shape != self.target.shape: 218 if strict_shape: 219 log_error( 220 f"Reference image shape {reference.shape} does not match target image shape {self.target.shape} for NamedScreenRegion {self.name}.", 221 self._parameters, 222 ) 223 else: 224 return float("inf") 225 diff = np.abs(reference.astype(np.float32) - self.target.astype(np.float32)) 226 mae = np.mean(diff) 227 return mae 228 229 def compare_against_multi_target( 230 self, target_name: str, reference: np.ndarray, strict_shape: bool = True 231 ) -> float: 232 """ 233 Computes the Absolute Error (AE) between the given reference image and one of the multiple target images. 234 235 :param self: Description 236 :param target_name: The name of the target image to compare against. 237 :type target_name: str 238 :param reference: The reference image to compare. 239 :type reference: np.ndarray 240 :param strict_shape: Whether to error out if the array shapes do not match. 241 :type strict_shape: bool 242 :return: The Absolute Error (AE) between the reference and specified target images. 243 :rtype: float 244 """ 245 if self.multi_targets is None or target_name not in self.multi_targets: 246 log_error( 247 f"No multi target image set for NamedScreenRegion {self.name} with target name {target_name}. Cannot compare.", 248 self._parameters, 249 ) 250 self.target = self.multi_targets[target_name] 251 mae = self.compare_against_target(reference, strict_shape) 252 self.target = None 253 return mae 254 255 def matches_target( 256 self, reference: np.ndarray, strict_shape: bool = True, epsilon=0.01 257 ) -> bool: 258 """ 259 Compares the given reference image to the target image using Absolute Error (AE). 260 261 :param self: Description 262 :param reference: The reference image to compare. 263 :type reference: np.ndarray 264 :param strict_shape: Whether to error out if the array shapes do not match. 265 :type strict_shape: bool 266 :param epsilon: The threshold for considering a match. 267 :return: True if the AE is below the epsilon threshold, False otherwise. 268 :rtype: bool 269 """ 270 mae = self.compare_against_target(reference, strict_shape) 271 if mae <= epsilon: 272 return True 273 return False 274 275 def matches_multi_target( 276 self, target_name: str, reference: np.ndarray, strict_shape: bool = True 277 ) -> bool: 278 """ 279 Compares the given reference image to one of the multiple target images using Absolute Error (AE). 280 281 :param target_name: The name of the target image to compare against. 282 :type target_name: str 283 :param reference: The reference image to compare. 284 :type reference: np.ndarray 285 :param strict_shape: Whether to error out if the array shapes do not match. 286 :type strict_shape: bool 287 :return: True if the AE is below the epsilon threshold, False otherwise. 288 :rtype: bool 289 """ 290 if self.multi_targets is None or target_name not in self.multi_targets: 291 log_error( 292 f"No multi target image set for NamedScreenRegion {self.name} with target name {target_name}. Cannot compare.", 293 self._parameters, 294 ) 295 self.target = self.multi_targets[target_name] 296 result = self.matches_target(reference, strict_shape) 297 self.target = None 298 return result 299 300 def matches_any_multi_target( 301 self, target_names: List[str], reference: np.ndarray, strict_shape: bool = True 302 ) -> bool: 303 """ 304 Compares the given reference image to all of the specified target images and returns True if any of them match. 305 306 :param target_names: The names of the target images to compare against. 307 :type target_names: List[str] 308 :param reference: The reference image to compare. 309 :type reference: np.ndarray 310 :param strict_shape: Whether to error out if the array shapes do not match. 311 :type strict_shape: bool 312 :return: True if the AE is below the epsilon threshold for any of the target images, False otherwise. 313 :rtype: bool 314 """ 315 for target_name in target_names: 316 if self.matches_multi_target(target_name, reference, strict_shape): 317 return True 318 return False
Saves a reference to a named screen region (always a rectangle) for easy access.
54 def __init__( 55 self, 56 name: str, 57 start_x: int, 58 start_y: int, 59 width: int, 60 height: int, 61 parameters: dict, 62 *, 63 target_path: Optional[str] = None, 64 multi_target_paths: Optional[Dict[str, str]] = None, 65 ): 66 """ 67 Initializes a named screen region. 68 69 Parameters: 70 name (str): The name of the screen region. 71 start_x (int): The starting x-coordinate of the region in pixel space of the full resolution game screen. 72 start_y (int): The starting y-coordinate of the region in pixel space of the full resolution game screen. 73 width (int): The width of the region in pixels. 74 height (int): The height of the region in pixels. 75 target (str): Optional path to a .npy file containing a screen capture of this region. Non-existent paths are only allowed if parameters['debug_mode'] (from configs/project_vars.yaml) is set to True. 76 multi_target_paths (Optional[Dict[str, str]]): Optional dictionary containing multiple possible paths to .npy files for this region. Keys are arbitrary strings, values are paths to .npy files. If provided, this will override target_path and force it None. Allows using the same region for multiple target images. 77 """ 78 if not isinstance(name, str): 79 log_error(f"name must be a string. Found {type(name)}", parameters) 80 if len(name.split()) > 1: 81 log_error( 82 f"name must be a single word with no spaces. Found {name}", parameters 83 ) 84 if "," in name: 85 log_error(f"name cannot contain commas. Found {name}", parameters) 86 self.name = name 87 """ Name of the screen region. """ 88 if ( 89 not isinstance(start_x, int) 90 or not isinstance(start_y, int) 91 or not isinstance(width, int) 92 or not isinstance(height, int) 93 ): 94 log_error( 95 f"start_x, start_y, width, and height must be integers. Found {type(start_x)}, {type(start_y)}, {type(width)}, {type(height)}", 96 parameters, 97 ) 98 self.start_x = start_x 99 """ The starting x-coordinate of the region. """ 100 self.start_y = start_y 101 """ The starting y-coordinate of the region. """ 102 self.width = width 103 """ The width of the region. """ 104 self.height = height 105 """ The height of the region. """ 106 self._parameters = parameters 107 self.target_path = None 108 """ Path to npy file of a screen capture that we will be comparing this region against. """ 109 self.target: Optional[np.ndarray] = None 110 """ Numpy array of the target image for this region. """ 111 self.multi_target_paths = multi_target_paths 112 self.multi_targets = None 113 """ Dictionary of multiple target paths for this region. """ 114 if multi_target_paths is not None: 115 self.multi_targets = {} 116 for key, path in multi_target_paths.items(): 117 if not isinstance(key, str): 118 log_error( 119 f"multi_target_paths keys must be strings. Found {type(key)}", 120 parameters, 121 ) 122 if len(key.split()) > 1: 123 log_error( 124 f"multi_target_paths keys must be single words with no spaces. Found {key}", 125 parameters, 126 ) 127 if "," in key: 128 log_error( 129 f"multi_target_paths keys cannot contain commas. Found {key}", 130 parameters, 131 ) 132 self.multi_targets[key] = self._sanity_load_target(path) 133 else: 134 if target_path is not None: 135 self.target_path = target_path 136 self.target = self._sanity_load_target(target_path)
Initializes a named screen region.
Arguments:
- name (str): The name of the screen region.
- start_x (int): The starting x-coordinate of the region in pixel space of the full resolution game screen.
- start_y (int): The starting y-coordinate of the region in pixel space of the full resolution game screen.
- width (int): The width of the region in pixels.
- height (int): The height of the region in pixels.
- target (str): Optional path to a .npy file containing a screen capture of this region. Non-existent paths are only allowed if parameters['debug_mode'] (from configs/project_vars.yaml) is set to True.
- multi_target_paths (Optional[Dict[str, str]]): Optional dictionary containing multiple possible paths to .npy files for this region. Keys are arbitrary strings, values are paths to .npy files. If provided, this will override target_path and force it None. Allows using the same region for multiple target images.
165 def get_end_x(self) -> int: 166 """ 167 Returns the end x-coordinate of the named screen region. 168 169 Returns: 170 int: The end x-coordinate of the named screen region. 171 """ 172 return self.start_x + self.width
Returns the end x-coordinate of the named screen region.
Returns:
int: The end x-coordinate of the named screen region.
174 def get_end_y(self) -> int: 175 """ 176 Returns the end y-coordinate of the named screen region. 177 Returns: 178 int: The end y-coordinate of the named screen region. 179 """ 180 return self.start_y + self.height
Returns the end y-coordinate of the named screen region.
Returns:
int: The end y-coordinate of the named screen region.
182 def get_corners(self) -> Tuple[int, int, int, int]: 183 """ 184 Returns the corners of the named screen region as (start_x, start_y, end_x, end_y). 185 186 :return: The corners of the named screen region. 187 :rtype: Tuple[int, int, int, int] 188 """ 189 return (self.start_x, self.start_y, self.get_end_x(), self.get_end_y())
Returns the corners of the named screen region as (start_x, start_y, end_x, end_y).
Returns
The corners of the named screen region.
197 def compare_against_target( 198 self, reference: np.ndarray, strict_shape: bool = True 199 ) -> float: 200 """ 201 Computes the Absolute Error (AE) between the given reference image and the target image. 202 203 :param reference: The reference image to compare. 204 :type reference: np.ndarray 205 :param strict_shape: Whether to error out if the array shapes do not match. 206 :type strict_shape: bool 207 :return: The Absolute Error (AE) between the reference and target images. 208 :rtype: float 209 """ 210 if self.target is None: 211 if self._parameters["debug_mode"]: 212 return float("inf") 213 log_error( 214 f"No target image set for NamedScreenRegion {self.name}. Cannot compare.", 215 self._parameters, 216 ) 217 if reference.shape != self.target.shape: 218 if strict_shape: 219 log_error( 220 f"Reference image shape {reference.shape} does not match target image shape {self.target.shape} for NamedScreenRegion {self.name}.", 221 self._parameters, 222 ) 223 else: 224 return float("inf") 225 diff = np.abs(reference.astype(np.float32) - self.target.astype(np.float32)) 226 mae = np.mean(diff) 227 return mae
Computes the Absolute Error (AE) between the given reference image and the target image.
Parameters
- reference: The reference image to compare.
- strict_shape: Whether to error out if the array shapes do not match.
Returns
The Absolute Error (AE) between the reference and target images.
229 def compare_against_multi_target( 230 self, target_name: str, reference: np.ndarray, strict_shape: bool = True 231 ) -> float: 232 """ 233 Computes the Absolute Error (AE) between the given reference image and one of the multiple target images. 234 235 :param self: Description 236 :param target_name: The name of the target image to compare against. 237 :type target_name: str 238 :param reference: The reference image to compare. 239 :type reference: np.ndarray 240 :param strict_shape: Whether to error out if the array shapes do not match. 241 :type strict_shape: bool 242 :return: The Absolute Error (AE) between the reference and specified target images. 243 :rtype: float 244 """ 245 if self.multi_targets is None or target_name not in self.multi_targets: 246 log_error( 247 f"No multi target image set for NamedScreenRegion {self.name} with target name {target_name}. Cannot compare.", 248 self._parameters, 249 ) 250 self.target = self.multi_targets[target_name] 251 mae = self.compare_against_target(reference, strict_shape) 252 self.target = None 253 return mae
Computes the Absolute Error (AE) between the given reference image and one of the multiple target images.
Parameters
- self: Description
- target_name: The name of the target image to compare against.
- reference: The reference image to compare.
- strict_shape: Whether to error out if the array shapes do not match.
Returns
The Absolute Error (AE) between the reference and specified target images.
255 def matches_target( 256 self, reference: np.ndarray, strict_shape: bool = True, epsilon=0.01 257 ) -> bool: 258 """ 259 Compares the given reference image to the target image using Absolute Error (AE). 260 261 :param self: Description 262 :param reference: The reference image to compare. 263 :type reference: np.ndarray 264 :param strict_shape: Whether to error out if the array shapes do not match. 265 :type strict_shape: bool 266 :param epsilon: The threshold for considering a match. 267 :return: True if the AE is below the epsilon threshold, False otherwise. 268 :rtype: bool 269 """ 270 mae = self.compare_against_target(reference, strict_shape) 271 if mae <= epsilon: 272 return True 273 return False
Compares the given reference image to the target image using Absolute Error (AE).
Parameters
- self: Description
- reference: The reference image to compare.
- strict_shape: Whether to error out if the array shapes do not match.
- epsilon: The threshold for considering a match.
Returns
True if the AE is below the epsilon threshold, False otherwise.
275 def matches_multi_target( 276 self, target_name: str, reference: np.ndarray, strict_shape: bool = True 277 ) -> bool: 278 """ 279 Compares the given reference image to one of the multiple target images using Absolute Error (AE). 280 281 :param target_name: The name of the target image to compare against. 282 :type target_name: str 283 :param reference: The reference image to compare. 284 :type reference: np.ndarray 285 :param strict_shape: Whether to error out if the array shapes do not match. 286 :type strict_shape: bool 287 :return: True if the AE is below the epsilon threshold, False otherwise. 288 :rtype: bool 289 """ 290 if self.multi_targets is None or target_name not in self.multi_targets: 291 log_error( 292 f"No multi target image set for NamedScreenRegion {self.name} with target name {target_name}. Cannot compare.", 293 self._parameters, 294 ) 295 self.target = self.multi_targets[target_name] 296 result = self.matches_target(reference, strict_shape) 297 self.target = None 298 return result
Compares the given reference image to one of the multiple target images using Absolute Error (AE).
Parameters
- target_name: The name of the target image to compare against.
- reference: The reference image to compare.
- strict_shape: Whether to error out if the array shapes do not match.
Returns
True if the AE is below the epsilon threshold, False otherwise.
300 def matches_any_multi_target( 301 self, target_names: List[str], reference: np.ndarray, strict_shape: bool = True 302 ) -> bool: 303 """ 304 Compares the given reference image to all of the specified target images and returns True if any of them match. 305 306 :param target_names: The names of the target images to compare against. 307 :type target_names: List[str] 308 :param reference: The reference image to compare. 309 :type reference: np.ndarray 310 :param strict_shape: Whether to error out if the array shapes do not match. 311 :type strict_shape: bool 312 :return: True if the AE is below the epsilon threshold for any of the target images, False otherwise. 313 :rtype: bool 314 """ 315 for target_name in target_names: 316 if self.matches_multi_target(target_name, reference, strict_shape): 317 return True 318 return False
Compares the given reference image to all of the specified target images and returns True if any of them match.
Parameters
- target_names: The names of the target images to compare against.
- reference: The reference image to compare.
- strict_shape: Whether to error out if the array shapes do not match.
Returns
True if the AE is below the epsilon threshold for any of the target images, False otherwise.
321class StateParser(ABC): 322 """ 323 Abstract base class for parsing game state variables from the GameBoy emulator. 324 """ 325 326 def __init__( 327 self, 328 pyboy, 329 parameters, 330 named_screen_regions: Optional[List[NamedScreenRegion]] = None, 331 ): 332 """ 333 Initializes the StateParser. Child implementations should call super().__init__() after running their code. 334 All children must create a self.rom_data_path variable 335 Args: 336 pyboy: An instance of the PyBoy emulator. 337 parameters: A dictionary of parameters for configuration. 338 named_screen_regions (Optional[list[NamedScreenRegion]]): A list of NamedScreenRegion objects for easy access to specific screen regions. 339 """ 340 verify_parameters(parameters) 341 self._parameters = parameters 342 if not hasattr(self, "rom_data_path"): 343 log_error( 344 f"StateParsers must define a self.rom_data_path variable pointing to the rom data path for the game variant.", 345 self._parameters, 346 ) 347 self.rom_data_path: str = self.rom_data_path 348 """ Path to the rom data directory for the game variant. """ 349 if not isinstance(pyboy, PyBoy): 350 log_error("pyboy must be an instance of PyBoy", self._parameters) 351 self._pyboy = pyboy 352 self.named_screen_regions: dict[str, NamedScreenRegion] = {} 353 """ Dictionary of NamedScreenRegion objects for easy access to specific screen regions. """ 354 if named_screen_regions is not None: 355 for region in named_screen_regions: 356 if not isinstance(region, NamedScreenRegion): 357 log_error( 358 f"named_screen_regions must be a list of NamedScreenRegion objects. Found {type(region)}", 359 self._parameters, 360 ) 361 if region.name in self.named_screen_regions: 362 log_error( 363 f"Duplicate named screen region: {region.name}", 364 self._parameters, 365 ) 366 self.named_screen_regions[region.name] = region 367 self.image_references = {} 368 """ Dictionary of image references loaded. """ 369 location = os.path.join(self.rom_data_path, "image_references") 370 if os.path.exists(location): 371 for file in os.listdir(location): 372 image_path = os.path.join(location, file) 373 if image_path.endswith((".png", ".jpg", ".jpeg")): 374 reference_name = file.rsplit(".", 1)[0] 375 image = Image.open(image_path) 376 self.image_references[reference_name] = image 377 else: 378 log_warn( 379 f"Found unsupported image extension {file} in {location}. Only place image files in this folder.", 380 self._parameters, 381 ) 382 383 @staticmethod 384 def bit_count(bits: int) -> int: 385 """ 386 Counts the number of set bits (1s) in the given integer. 387 Args: 388 bits (int): The integer to count set bits in. 389 Returns: 390 int: The number of set bits. 391 """ 392 return bin(bits).count("1") 393 394 def read_m(self, addr: bytes) -> int: 395 """ 396 Reads a byte from the specified memory address. 397 Args: 398 addr (int): The memory address to read from. 399 Returns: 400 int: The byte value at the specified memory address. 401 """ 402 # return self.pyboy.get_memory_value(addr) 403 return self._pyboy.memory[addr] 404 405 def read_bits(self, addr) -> str: 406 """ 407 Reads a memory address and returns the result as a binary string. Adds padding so that reading bit 0 works correctly. 408 Args: 409 addr (int): The memory address to read from. 410 Returns: 411 str: The binary string representation of the byte at the specified memory address. 412 """ 413 # add padding so zero will read '0b100000000' instead of '0b0' 414 return bin(256 + self.read_m(addr)) 415 416 def read_bit(self, addr, bit: int) -> bool: 417 """ 418 Reads a specific bit from a memory address. 419 Args: 420 addr (int): The memory address to read from. 421 bit (int): The bit position to read (0-7). 422 Returns: 423 bool: True if the bit is set (1), False otherwise. 424 """ 425 # add padding so zero will read '0b100000000' instead of '0b0' 426 return self.read_bits(addr)[-bit - 1] == "1" 427 428 def read_m_bit(self, addr_bit: str) -> bool: 429 """ 430 Reads a specific addr-bit string from a memory address. 431 Args: 432 addr_bit (str): The - concatenation of a memory address and the bit position (e.g. '0xD87D-5') 433 Returns: 434 bool: True if the bit at that memory address is set (1), False otherwise 435 """ 436 if "-" not in addr_bit: 437 log_error(f"Incorrect format addr_bit: {addr_bit}", self._parameters) 438 addr, bit = addr_bit.split("-") 439 flag = False 440 try: 441 addr = eval(addr) 442 except: 443 flag = True 444 if flag: 445 log_error( 446 f"Could not eval byte string: {addr}. Check format", self._parameters 447 ) 448 if not bit.isdigit(): 449 log_error(f"bit {bit} is not digit", self._parameters) 450 bit = int(bit) 451 return self.read_bit(addr, bit) 452 453 def get_raised_flags(self, item_dict: dict) -> set: 454 """ 455 Reads a dictionary of the form {flag_name: memory_address-bit} and returns a set of all flag names that are currently raised (i.e. the bit at the memory address is 1). 456 Args: 457 item_dict (dict): A dictionary mapping flag names to memory address-bit strings. 458 Returns: 459 set: A set of flag names that are currently raised. 460 """ 461 items = set() 462 for item_name, slot in item_dict.items(): 463 if self.read_m_bit(slot): 464 items.add(item_name) 465 return items 466 467 def get_current_frame(self) -> np.ndarray: 468 """ 469 Reads the pyboy screen and returns a full resolution numpy array 470 471 Returns: 472 np.ndarray: The rendered image as a numpy array. 473 """ 474 screen = self._pyboy.screen.ndarray[ 475 :, :, 0:1 476 ] # (144, 160, 3) but force just greyscale 477 return screen.copy() 478 479 @staticmethod 480 def capture_box( 481 current_frame: np.ndarray, 482 start_x: int, 483 start_y: int, 484 width: int, 485 height: int, 486 ) -> np.ndarray: 487 """ 488 Captures a rectangular region from the current frame. 489 490 Args: 491 current_frame (np.ndarray): The current frame from the emulator. 492 start_x (int): The starting x-coordinate of the region. 493 start_y (int): The starting y-coordinate of the region. 494 width (int): The width of the region. 495 height (int): The height of the region. 496 Returns: 497 np.ndarray: The captured rectangular region. 498 """ 499 # first check that the box is within the frame 500 end_x = start_x + width 501 end_y = start_y + height 502 if ( 503 start_x < 0 504 or start_y < 0 505 or end_x > current_frame.shape[1] 506 or end_y > current_frame.shape[0] 507 ): 508 start_x = max(0, start_x) 509 start_y = max(0, start_y) 510 end_x = min(current_frame.shape[1], end_x) 511 end_y = min(current_frame.shape[0], end_y) 512 return current_frame[start_y:end_y, start_x:end_x, :] 513 514 @staticmethod 515 def capture_square_centered( 516 current_frame: np.ndarray, center_x: int, center_y: int, box_size: int 517 ) -> np.ndarray: 518 """ 519 Captures a square region from the current frame centered at (center_x, center_y) with the given box size. 520 521 Args: 522 current_frame (np.ndarray): The current frame from the emulator. 523 center_x (int): The x-coordinate of the center of the square. 524 center_y (int): The y-coordinate of the center of the square. 525 box_size (int): The size of the square box to capture. 526 527 Returns: 528 np.ndarray: The captured square region. 529 """ 530 half_box = box_size // 2 531 start_x = max(center_x - half_box, 0) 532 end_x = min(center_x + half_box, current_frame.shape[1]) 533 start_y = max(center_y - half_box, 0) 534 end_y = min(center_y + half_box, current_frame.shape[0]) 535 return current_frame[start_y:end_y, start_x:end_x, :] 536 537 @staticmethod 538 def draw_box( 539 current_frame: np.ndarray, 540 start_x: int, 541 start_y: int, 542 width: int, 543 height: int, 544 color: tuple = (0, 0, 0), 545 thickness: int = 1, 546 ) -> np.ndarray: 547 """ 548 Draws a rectangle on the current frame. 549 550 Args: 551 current_frame (np.ndarray): The current frame from the emulator. 552 start_x (int): The starting x-coordinate of the rectangle. 553 start_y (int): The starting y-coordinate of the rectangle. 554 width (int): The width of the rectangle. 555 height (int): The height of the rectangle. 556 color (tuple, optional): The color of the rectangle in BGR format. 557 thickness (int, optional): The thickness of the rectangle border. 558 559 Returns: 560 np.ndarray: The frame with the drawn rectangle. 561 """ 562 end_x = start_x + width 563 end_y = start_y + height 564 if ( 565 start_x < 0 566 or start_y < 0 567 or end_x > current_frame.shape[1] 568 or end_y > current_frame.shape[0] 569 ): 570 start_x = max(0, start_x) 571 start_y = max(0, start_y) 572 end_x = min(current_frame.shape[1], end_x) 573 end_y = min(current_frame.shape[0], end_y) 574 frame_with_box = current_frame.copy() 575 cv2 = import_cv2(None) 576 cv2.rectangle( 577 frame_with_box, (start_x, start_y), (end_x, end_y), color, thickness 578 ) 579 return frame_with_box 580 581 @staticmethod 582 def draw_square_centered( 583 current_frame: np.ndarray, 584 center_x: int, 585 center_y: int, 586 box_size: int, 587 color: tuple = (0, 0, 0), 588 thickness: int = 1, 589 ) -> np.ndarray: 590 """ 591 Draws a square on the current frame centered at (center_x, center_y) with the given box size. 592 593 Args: 594 current_frame (np.ndarray): The current frame from the emulator. 595 center_x (int): The x-coordinate of the center of the square. 596 center_y (int): The y-coordinate of the center of the square. 597 box_size (int): The size of the square box to draw. 598 color (tuple, optional): The color of the square in BGR format. 599 thickness (int, optional): The thickness of the square border. 600 601 Returns: 602 np.ndarray: The frame with the drawn square. 603 """ 604 half_box = box_size // 2 605 start_x = max(center_x - half_box, 0) 606 end_x = min(center_x + half_box, current_frame.shape[1]) 607 start_y = max(center_y - half_box, 0) 608 end_y = min(center_y + half_box, current_frame.shape[0]) 609 frame_with_square = current_frame.copy() 610 cv2 = import_cv2(None) 611 cv2.rectangle( 612 frame_with_square, (start_x, start_y), (end_x, end_y), color, thickness 613 ) 614 return frame_with_square 615 616 def capture_named_region(self, current_frame: np.ndarray, name: str) -> np.ndarray: 617 """ 618 Captures a named region from the current frame. 619 620 Args: 621 current_frame (np.ndarray): The current frame from the emulator. 622 name (str): The name of the region to capture. 623 624 Returns: 625 np.ndarray: The captured region. 626 """ 627 if name not in self.named_screen_regions: 628 log_error(f"Named screen region {name} not found.", self._parameters) 629 region = self.named_screen_regions[name] 630 x, y, w, h = region.start_x, region.start_y, region.width, region.height 631 return self.capture_box(current_frame, x, y, w, h) 632 633 def compare_named_region_against_target( 634 self, current_frame: np.ndarray, name: str, strict_shape: bool = True 635 ) -> float: 636 """ 637 Computes the Absolute Error (AE) between a named region from the current frame and its target image. 638 639 Args: 640 current_frame (np.ndarray): The current frame from the emulator. 641 name (str): The name of the region to compare. 642 strict_shape (bool, optional): Whether to error out if the array shapes do not match. 643 Returns: 644 float: The Absolute Error (AE) between the named region and its target image. 645 """ 646 if name not in self.named_screen_regions: 647 log_error(f"Named screen region {name} not found.", self._parameters) 648 region = self.named_screen_regions[name] 649 captured_region = self.capture_named_region(current_frame, name) 650 return region.compare_against_target(captured_region, strict_shape) 651 652 def named_region_matches_target(self, current_frame: np.ndarray, name: str) -> bool: 653 """ 654 Compares a named region from the current frame to its target image using Absolute Error (AE). 655 656 Args: 657 current_frame (np.ndarray): The current frame from the emulator. 658 name (str): The name of the region to compare. 659 Returns: 660 bool: True if the region matches the target image, False otherwise. 661 """ 662 if name not in self.named_screen_regions: 663 log_error(f"Named screen region {name} not found.", self._parameters) 664 region = self.named_screen_regions[name] 665 captured_region = self.capture_named_region(current_frame, name) 666 return region.matches_target(captured_region) 667 668 def compare_named_region_against_multi_target( 669 self, 670 current_frame: np.ndarray, 671 name: str, 672 target_name: str, 673 strict_shape: bool = True, 674 ) -> float: 675 """ 676 Computes the Absolute Error (AE) between a named region from the current frame and one of its multiple target images. 677 678 Args: 679 current_frame (np.ndarray): The current frame from the emulator. 680 name (str): The name of the region to compare. 681 target_name (str): The name of the target image to compare against. 682 strict_shape (bool, optional): Whether to error out if the array shapes do not match. 683 Returns: 684 float: The Absolute Error (AE) between the named region and the specified target image. 685 """ 686 if name not in self.named_screen_regions: 687 log_error(f"Named screen region {name} not found.", self._parameters) 688 region = self.named_screen_regions[name] 689 captured_region = self.capture_named_region(current_frame, name) 690 return region.compare_against_multi_target( 691 target_name, captured_region, strict_shape 692 ) 693 694 def named_region_matches_multi_target( 695 self, current_frame: np.ndarray, name: str, target_name: str 696 ) -> bool: 697 """ 698 Compares a named region from the current frame to one of its multiple target images using Absolute Error (AE). 699 700 Args: 701 current_frame (np.ndarray): The current frame from the emulator. 702 name (str): The name of the region to compare. 703 target_name (str): The name of the target image to compare against. 704 Returns: 705 bool: True if the region matches the specified target image, False otherwise. 706 """ 707 if name not in self.named_screen_regions: 708 log_error(f"Named screen region {name} not found.", self._parameters) 709 region = self.named_screen_regions[name] 710 captured_region = self.capture_named_region(current_frame, name) 711 return region.matches_multi_target(target_name, captured_region) 712 713 def draw_named_region( 714 self, 715 current_frame: np.ndarray, 716 name: str, 717 color: tuple = (0, 0, 0), 718 thickness: int = 1, 719 ) -> np.ndarray: 720 """ 721 Draws a named region on the current frame. 722 723 Args: 724 current_frame (np.ndarray): The current frame from the emulator. 725 name (str): The name of the region to draw. 726 color (tuple, optional): The color of the rectangle in BGR format. 727 thickness (int, optional): The thickness of the rectangle border. 728 729 Returns: 730 np.ndarray: The frame with the drawn rectangle. 731 """ 732 if name not in self.named_screen_regions: 733 log_error(f"Named screen region {name} not found.", self._parameters) 734 region = self.named_screen_regions[name] 735 x, y, w, h = region.start_x, region.start_y, region.width, region.height 736 return self.draw_box(current_frame, x, y, w, h, color, thickness) 737 738 @staticmethod 739 def draw_grid_overlay( 740 current_frame: np.ndarray, grid_skip: int = 16, x_offset=0, y_offset=-2 741 ) -> np.ndarray: 742 """ 743 Draws a grid overlay on the current frame for easier region identification. 744 Args: 745 current_frame (np.ndarray): The current frame from the emulator. 746 grid_skip (int, optional): The number of pixels between grid lines. 747 x_offset (int, optional): The x-offset to apply when drawing the grid. 748 y_offset (int, optional): The y-offset to apply when drawing the grid. 749 Returns: 750 np.ndarray: The frame with the grid overlay. 751 """ 752 frame_with_grid = current_frame.copy() 753 cv2 = import_cv2(None) 754 for x in range(0, current_frame.shape[1], grid_skip): 755 cv2.line( 756 frame_with_grid, 757 (x + x_offset, 0), 758 (x + x_offset, current_frame.shape[0]), 759 (0, 0, 255), 760 1, 761 lineType=cv2.LINE_AA, 762 ) 763 for y in range(0, current_frame.shape[0], grid_skip): 764 cv2.line( 765 frame_with_grid, 766 (0, y + y_offset), 767 (current_frame.shape[1], y + y_offset), 768 (0, 0, 255), 769 1, 770 lineType=cv2.LINE_AA, 771 ) 772 return frame_with_grid 773 774 @staticmethod 775 def capture_grid_cells( 776 current_frame: np.ndarray, 777 *, 778 quadrant: str = None, 779 grid_skip: int = 16, 780 x_offset=0, 781 y_offset=-2, 782 ) -> Dict[Tuple[int, int], np.ndarray]: 783 """ 784 Captures all grid cells from the current frame based on the specified grid skip. 785 786 Example: 787 ```python 788 import matplotlib.pyplot as plt 789 # ... run the state_parser in an env, example in dev_play. 790 grid_cells = StateParser.capture_grid_cells(current_frame) 791 keep_keys = [(0, 0), (0, 1)] 792 new_cells = {} 793 for cell in keep_keys: 794 new_cells[cell] = grid_cells[cell] 795 grid_cells = new_cells 796 drawn_frame = self.state_parser.reform_image(grid_cells) 797 quadrants = self.state_parser.get_quadrant_frame(grid_cells=grid_cells) 798 plt.imshow(drawn_frame[:, :, 0], cmap="gray") 799 plt.title(f"Full Screen with Grid Overlay") 800 plt.show() 801 merged = self.state_parser.reform_image(grid_cells) 802 plt.imshow(merged[:, :, 0], cmap="gray") 803 plt.show() 804 ``` 805 806 :param current_frame: An emulator frame. 807 :type current_frame: np.ndarray 808 :param quadrant: If specified, only captures cells in the given quadrant ('TL', 'TR', 'BL', 'BR'). 809 :type quadrant: str 810 :param grid_skip: The number of pixels between grid lines. 811 :type grid_skip: int 812 :param x_offset: The x-offset to apply when capturing cells. 813 :param y_offset: The y-offset to apply when capturing cells. 814 :return: A dictionary mapping grid cell coordinates to their captured images. 815 The grid cells are with the central cell as (0,0) 816 :rtype: Dict[Tuple[int, int], ndarray[_AnyShape, dtype[Any]]] 817 """ 818 if quadrant is not None: 819 if quadrant.lower() not in ["tl", "tr", "bl", "br"]: 820 log_error( 821 f"Invalid quadrant: {quadrant}. Must be one of 'TL', 'TR', 'BL', 'BR'", 822 ) 823 cells = {} 824 if x_offset != 0: 825 x_iter = [-x_offset] + list(range(0, current_frame.shape[1], grid_skip)) 826 else: 827 x_iter = list(range(0, current_frame.shape[1], grid_skip)) 828 if y_offset != 0: 829 y_iter = [-y_offset] + list(range(0, current_frame.shape[0], grid_skip)) 830 else: 831 y_iter = list(range(0, current_frame.shape[0], grid_skip)) 832 833 def x_ind(x): 834 index = x_iter.index(x) 835 return (index - (len(x_iter)) // 2) + 1 * (x_offset == 0) 836 837 def y_ind(y): 838 index = y_iter.index(y) 839 return -(index - len(y_iter) // 2) + 1 * (y_offset == 0) 840 841 for x in x_iter: 842 for y in y_iter: 843 x_cell = x_ind(x) 844 y_cell = y_ind(y) 845 if quadrant is not None: 846 if quadrant.lower() == "tl" and (x_cell > 0 or y_cell < 0): 847 continue 848 elif quadrant.lower() == "tr" and (x_cell < 0 or y_cell < 0): 849 continue 850 elif quadrant.lower() == "bl" and (x_cell > 0 or y_cell > 0): 851 continue 852 elif quadrant.lower() == "br" and (x_cell < 0 or y_cell > 0): 853 continue 854 cell_image = StateParser.capture_box( 855 current_frame, x + x_offset, y + y_offset, grid_skip, grid_skip 856 ) 857 cells[(x_cell, y_cell)] = cell_image 858 return cells 859 860 @staticmethod 861 def reform_image(grid_cells: Dict[Tuple[int, int], np.ndarray]) -> np.ndarray: 862 """ 863 Reform the image from grid cells back into a single image. 864 Expects the grid_cells to correspond to a rectangle. 865 Args: 866 grid_cells (Dict[Tuple[int, int], np.ndarray]): A dictionary mapping (x, y) coordinates to image cells. 867 868 Returns: 869 np.ndarray: The reformed image. 870 """ 871 coords = grid_cells.keys() 872 if len(coords) == 1: 873 return list(grid_cells.values())[0] 874 xs = list(set([coord[0] for coord in coords])) 875 ys = list(set([coord[1] for coord in coords])) 876 xs.sort() 877 ys.sort() 878 rows = [] 879 for y in ys: 880 row_cells = [] 881 for x in xs: 882 row_cells.append(grid_cells[(x, y)]) 883 row_image = np.concatenate(row_cells, axis=1) 884 rows.append(row_image) 885 new_rows = [] 886 887 if len(rows) == 1: 888 new_rows.append(rows[0]) 889 else: 890 # This part is super hacky. 891 # Sometimes, the last and second last row are the exact same. In that case, skip the last row. I don't know man. 892 # show_frames(rows) 893 is_same = rows[-1].shape != rows[-2].shape 894 back_offset = 2 if is_same else 1 895 for item in range(len(rows) - back_offset, -1, -1): 896 new_rows.append(rows[item]) 897 full_image = np.concatenate(new_rows, axis=0) 898 return full_image 899 900 def get_quadrant_frame( 901 self, grid_cells: Dict[Tuple[int, int], np.ndarray] = None 902 ) -> Dict[str, Dict[str, Union[np.ndarray, Dict[Tuple[int, int], np.ndarray]]]]: 903 """ 904 Divides the current frame or subframe into quadrants and returns groups of quadrants 905 906 :param grid_cells: Subset of grid cells to split. Must be a rectangular box in (x, y) space. 907 :type grid_cells: Dict[Tuple[int, int], np.ndarray] 908 :return: A dictionary where the keys are quadrant keys [tr, tl, br, bl] and values are: 909 910 - screen: which maps to the single numpy array representing that quadrant as a screen 911 - cells: A dictionary mapping cell grids to the specific screen region as numpy arrays. 912 :rtype: Dict[str, Dict[str, Union[np.ndarray, Dict[Tuple[int, int], np.ndarray]]]] 913 """ 914 if grid_cells is None: 915 grid_cells = self.capture_grid_cells(self.get_current_frame()) 916 coords = grid_cells.keys() 917 xs = list(set([coord[0] for coord in coords])) 918 ys = list(set([coord[1] for coord in coords])) 919 xs.sort() 920 ys.sort() 921 mid_x = xs[len(xs) // 2] 922 mid_y = ys[len(ys) // 2] 923 quadrants = { 924 "tl": {"screen": None, "cells": {}}, 925 "tr": {"screen": None, "cells": {}}, 926 "bl": {"screen": None, "cells": {}}, 927 "br": {"screen": None, "cells": {}}, 928 } 929 lower_x = [x for x in xs if x < mid_x] 930 higher_x = [x for x in xs if x >= mid_x] 931 lower_y = [y for y in ys if y < mid_y] 932 higher_y = [y for y in ys if y >= mid_y] 933 for x in lower_x: 934 for y in higher_y: 935 quadrants["tl"]["cells"][(x, y)] = grid_cells[(x, y)] 936 quadrants["tl"]["screen"] = self.reform_image(quadrants["tl"]["cells"]) 937 for x in higher_x: 938 for y in higher_y: 939 quadrants["tr"]["cells"][(x, y)] = grid_cells[(x, y)] 940 quadrants["tr"]["screen"] = self.reform_image(quadrants["tr"]["cells"]) 941 for x in lower_x: 942 for y in lower_y: 943 quadrants["bl"]["cells"][(x, y)] = grid_cells[(x, y)] 944 quadrants["bl"]["screen"] = self.reform_image(quadrants["bl"]["cells"]) 945 for x in higher_x: 946 for y in lower_y: 947 quadrants["br"]["cells"][(x, y)] = grid_cells[(x, y)] 948 quadrants["br"]["screen"] = self.reform_image(quadrants["br"]["cells"]) 949 return quadrants 950 951 def get_image_reference(self, reference_name: str) -> Image.Image: 952 """ 953 Gets an image reference from the loaded image references. 954 Args: 955 reference_name (str): The name of the image reference to load 956 Returns: 957 Image.Image: The loaded image reference. 958 """ 959 if reference_name not in self.image_references: 960 log_error( 961 f"Image reference {reference_name} not found. Available options: {self.image_references.keys()}. If you want to add an image reference, add a file to the image_references folder.", 962 self._parameters, 963 ) 964 return self.image_references[reference_name] 965 966 @abstractmethod 967 def __repr__(self) -> str: 968 """ 969 Name of the parser for logging purposes. 970 :return: string name of the parser 971 """ 972 raise NotImplementedError
Abstract base class for parsing game state variables from the GameBoy emulator.
326 def __init__( 327 self, 328 pyboy, 329 parameters, 330 named_screen_regions: Optional[List[NamedScreenRegion]] = None, 331 ): 332 """ 333 Initializes the StateParser. Child implementations should call super().__init__() after running their code. 334 All children must create a self.rom_data_path variable 335 Args: 336 pyboy: An instance of the PyBoy emulator. 337 parameters: A dictionary of parameters for configuration. 338 named_screen_regions (Optional[list[NamedScreenRegion]]): A list of NamedScreenRegion objects for easy access to specific screen regions. 339 """ 340 verify_parameters(parameters) 341 self._parameters = parameters 342 if not hasattr(self, "rom_data_path"): 343 log_error( 344 f"StateParsers must define a self.rom_data_path variable pointing to the rom data path for the game variant.", 345 self._parameters, 346 ) 347 self.rom_data_path: str = self.rom_data_path 348 """ Path to the rom data directory for the game variant. """ 349 if not isinstance(pyboy, PyBoy): 350 log_error("pyboy must be an instance of PyBoy", self._parameters) 351 self._pyboy = pyboy 352 self.named_screen_regions: dict[str, NamedScreenRegion] = {} 353 """ Dictionary of NamedScreenRegion objects for easy access to specific screen regions. """ 354 if named_screen_regions is not None: 355 for region in named_screen_regions: 356 if not isinstance(region, NamedScreenRegion): 357 log_error( 358 f"named_screen_regions must be a list of NamedScreenRegion objects. Found {type(region)}", 359 self._parameters, 360 ) 361 if region.name in self.named_screen_regions: 362 log_error( 363 f"Duplicate named screen region: {region.name}", 364 self._parameters, 365 ) 366 self.named_screen_regions[region.name] = region 367 self.image_references = {} 368 """ Dictionary of image references loaded. """ 369 location = os.path.join(self.rom_data_path, "image_references") 370 if os.path.exists(location): 371 for file in os.listdir(location): 372 image_path = os.path.join(location, file) 373 if image_path.endswith((".png", ".jpg", ".jpeg")): 374 reference_name = file.rsplit(".", 1)[0] 375 image = Image.open(image_path) 376 self.image_references[reference_name] = image 377 else: 378 log_warn( 379 f"Found unsupported image extension {file} in {location}. Only place image files in this folder.", 380 self._parameters, 381 )
Initializes the StateParser. Child implementations should call super().__init__() after running their code. All children must create a self.rom_data_path variable
Arguments:
- pyboy: An instance of the PyBoy emulator.
- parameters: A dictionary of parameters for configuration.
- named_screen_regions (Optional[list[NamedScreenRegion]]): A list of NamedScreenRegion objects for easy access to specific screen regions.
Dictionary of NamedScreenRegion objects for easy access to specific screen regions.
383 @staticmethod 384 def bit_count(bits: int) -> int: 385 """ 386 Counts the number of set bits (1s) in the given integer. 387 Args: 388 bits (int): The integer to count set bits in. 389 Returns: 390 int: The number of set bits. 391 """ 392 return bin(bits).count("1")
Counts the number of set bits (1s) in the given integer.
Arguments:
- bits (int): The integer to count set bits in.
Returns:
int: The number of set bits.
394 def read_m(self, addr: bytes) -> int: 395 """ 396 Reads a byte from the specified memory address. 397 Args: 398 addr (int): The memory address to read from. 399 Returns: 400 int: The byte value at the specified memory address. 401 """ 402 # return self.pyboy.get_memory_value(addr) 403 return self._pyboy.memory[addr]
Reads a byte from the specified memory address.
Arguments:
- addr (int): The memory address to read from.
Returns:
int: The byte value at the specified memory address.
405 def read_bits(self, addr) -> str: 406 """ 407 Reads a memory address and returns the result as a binary string. Adds padding so that reading bit 0 works correctly. 408 Args: 409 addr (int): The memory address to read from. 410 Returns: 411 str: The binary string representation of the byte at the specified memory address. 412 """ 413 # add padding so zero will read '0b100000000' instead of '0b0' 414 return bin(256 + self.read_m(addr))
Reads a memory address and returns the result as a binary string. Adds padding so that reading bit 0 works correctly.
Arguments:
- addr (int): The memory address to read from.
Returns:
str: The binary string representation of the byte at the specified memory address.
416 def read_bit(self, addr, bit: int) -> bool: 417 """ 418 Reads a specific bit from a memory address. 419 Args: 420 addr (int): The memory address to read from. 421 bit (int): The bit position to read (0-7). 422 Returns: 423 bool: True if the bit is set (1), False otherwise. 424 """ 425 # add padding so zero will read '0b100000000' instead of '0b0' 426 return self.read_bits(addr)[-bit - 1] == "1"
Reads a specific bit from a memory address.
Arguments:
- addr (int): The memory address to read from.
- bit (int): The bit position to read (0-7).
Returns:
bool: True if the bit is set (1), False otherwise.
428 def read_m_bit(self, addr_bit: str) -> bool: 429 """ 430 Reads a specific addr-bit string from a memory address. 431 Args: 432 addr_bit (str): The - concatenation of a memory address and the bit position (e.g. '0xD87D-5') 433 Returns: 434 bool: True if the bit at that memory address is set (1), False otherwise 435 """ 436 if "-" not in addr_bit: 437 log_error(f"Incorrect format addr_bit: {addr_bit}", self._parameters) 438 addr, bit = addr_bit.split("-") 439 flag = False 440 try: 441 addr = eval(addr) 442 except: 443 flag = True 444 if flag: 445 log_error( 446 f"Could not eval byte string: {addr}. Check format", self._parameters 447 ) 448 if not bit.isdigit(): 449 log_error(f"bit {bit} is not digit", self._parameters) 450 bit = int(bit) 451 return self.read_bit(addr, bit)
Reads a specific addr-bit string from a memory address.
Arguments:
- addr_bit (str): The - concatenation of a memory address and the bit position (e.g. '0xD87D-5')
Returns:
bool: True if the bit at that memory address is set (1), False otherwise
453 def get_raised_flags(self, item_dict: dict) -> set: 454 """ 455 Reads a dictionary of the form {flag_name: memory_address-bit} and returns a set of all flag names that are currently raised (i.e. the bit at the memory address is 1). 456 Args: 457 item_dict (dict): A dictionary mapping flag names to memory address-bit strings. 458 Returns: 459 set: A set of flag names that are currently raised. 460 """ 461 items = set() 462 for item_name, slot in item_dict.items(): 463 if self.read_m_bit(slot): 464 items.add(item_name) 465 return items
Reads a dictionary of the form {flag_name: memory_address-bit} and returns a set of all flag names that are currently raised (i.e. the bit at the memory address is 1).
Arguments:
- item_dict (dict): A dictionary mapping flag names to memory address-bit strings.
Returns:
set: A set of flag names that are currently raised.
467 def get_current_frame(self) -> np.ndarray: 468 """ 469 Reads the pyboy screen and returns a full resolution numpy array 470 471 Returns: 472 np.ndarray: The rendered image as a numpy array. 473 """ 474 screen = self._pyboy.screen.ndarray[ 475 :, :, 0:1 476 ] # (144, 160, 3) but force just greyscale 477 return screen.copy()
Reads the pyboy screen and returns a full resolution numpy array
Returns:
np.ndarray: The rendered image as a numpy array.
479 @staticmethod 480 def capture_box( 481 current_frame: np.ndarray, 482 start_x: int, 483 start_y: int, 484 width: int, 485 height: int, 486 ) -> np.ndarray: 487 """ 488 Captures a rectangular region from the current frame. 489 490 Args: 491 current_frame (np.ndarray): The current frame from the emulator. 492 start_x (int): The starting x-coordinate of the region. 493 start_y (int): The starting y-coordinate of the region. 494 width (int): The width of the region. 495 height (int): The height of the region. 496 Returns: 497 np.ndarray: The captured rectangular region. 498 """ 499 # first check that the box is within the frame 500 end_x = start_x + width 501 end_y = start_y + height 502 if ( 503 start_x < 0 504 or start_y < 0 505 or end_x > current_frame.shape[1] 506 or end_y > current_frame.shape[0] 507 ): 508 start_x = max(0, start_x) 509 start_y = max(0, start_y) 510 end_x = min(current_frame.shape[1], end_x) 511 end_y = min(current_frame.shape[0], end_y) 512 return current_frame[start_y:end_y, start_x:end_x, :]
Captures a rectangular region from the current frame.
Arguments:
- current_frame (np.ndarray): The current frame from the emulator.
- start_x (int): The starting x-coordinate of the region.
- start_y (int): The starting y-coordinate of the region.
- width (int): The width of the region.
- height (int): The height of the region.
Returns:
np.ndarray: The captured rectangular region.
514 @staticmethod 515 def capture_square_centered( 516 current_frame: np.ndarray, center_x: int, center_y: int, box_size: int 517 ) -> np.ndarray: 518 """ 519 Captures a square region from the current frame centered at (center_x, center_y) with the given box size. 520 521 Args: 522 current_frame (np.ndarray): The current frame from the emulator. 523 center_x (int): The x-coordinate of the center of the square. 524 center_y (int): The y-coordinate of the center of the square. 525 box_size (int): The size of the square box to capture. 526 527 Returns: 528 np.ndarray: The captured square region. 529 """ 530 half_box = box_size // 2 531 start_x = max(center_x - half_box, 0) 532 end_x = min(center_x + half_box, current_frame.shape[1]) 533 start_y = max(center_y - half_box, 0) 534 end_y = min(center_y + half_box, current_frame.shape[0]) 535 return current_frame[start_y:end_y, start_x:end_x, :]
Captures a square region from the current frame centered at (center_x, center_y) with the given box size.
Arguments:
- current_frame (np.ndarray): The current frame from the emulator.
- center_x (int): The x-coordinate of the center of the square.
- center_y (int): The y-coordinate of the center of the square.
- box_size (int): The size of the square box to capture.
Returns:
np.ndarray: The captured square region.
537 @staticmethod 538 def draw_box( 539 current_frame: np.ndarray, 540 start_x: int, 541 start_y: int, 542 width: int, 543 height: int, 544 color: tuple = (0, 0, 0), 545 thickness: int = 1, 546 ) -> np.ndarray: 547 """ 548 Draws a rectangle on the current frame. 549 550 Args: 551 current_frame (np.ndarray): The current frame from the emulator. 552 start_x (int): The starting x-coordinate of the rectangle. 553 start_y (int): The starting y-coordinate of the rectangle. 554 width (int): The width of the rectangle. 555 height (int): The height of the rectangle. 556 color (tuple, optional): The color of the rectangle in BGR format. 557 thickness (int, optional): The thickness of the rectangle border. 558 559 Returns: 560 np.ndarray: The frame with the drawn rectangle. 561 """ 562 end_x = start_x + width 563 end_y = start_y + height 564 if ( 565 start_x < 0 566 or start_y < 0 567 or end_x > current_frame.shape[1] 568 or end_y > current_frame.shape[0] 569 ): 570 start_x = max(0, start_x) 571 start_y = max(0, start_y) 572 end_x = min(current_frame.shape[1], end_x) 573 end_y = min(current_frame.shape[0], end_y) 574 frame_with_box = current_frame.copy() 575 cv2 = import_cv2(None) 576 cv2.rectangle( 577 frame_with_box, (start_x, start_y), (end_x, end_y), color, thickness 578 ) 579 return frame_with_box
Draws a rectangle on the current frame.
Arguments:
- current_frame (np.ndarray): The current frame from the emulator.
- start_x (int): The starting x-coordinate of the rectangle.
- start_y (int): The starting y-coordinate of the rectangle.
- width (int): The width of the rectangle.
- height (int): The height of the rectangle.
- color (tuple, optional): The color of the rectangle in BGR format.
- thickness (int, optional): The thickness of the rectangle border.
Returns:
np.ndarray: The frame with the drawn rectangle.
581 @staticmethod 582 def draw_square_centered( 583 current_frame: np.ndarray, 584 center_x: int, 585 center_y: int, 586 box_size: int, 587 color: tuple = (0, 0, 0), 588 thickness: int = 1, 589 ) -> np.ndarray: 590 """ 591 Draws a square on the current frame centered at (center_x, center_y) with the given box size. 592 593 Args: 594 current_frame (np.ndarray): The current frame from the emulator. 595 center_x (int): The x-coordinate of the center of the square. 596 center_y (int): The y-coordinate of the center of the square. 597 box_size (int): The size of the square box to draw. 598 color (tuple, optional): The color of the square in BGR format. 599 thickness (int, optional): The thickness of the square border. 600 601 Returns: 602 np.ndarray: The frame with the drawn square. 603 """ 604 half_box = box_size // 2 605 start_x = max(center_x - half_box, 0) 606 end_x = min(center_x + half_box, current_frame.shape[1]) 607 start_y = max(center_y - half_box, 0) 608 end_y = min(center_y + half_box, current_frame.shape[0]) 609 frame_with_square = current_frame.copy() 610 cv2 = import_cv2(None) 611 cv2.rectangle( 612 frame_with_square, (start_x, start_y), (end_x, end_y), color, thickness 613 ) 614 return frame_with_square
Draws a square on the current frame centered at (center_x, center_y) with the given box size.
Arguments:
- current_frame (np.ndarray): The current frame from the emulator.
- center_x (int): The x-coordinate of the center of the square.
- center_y (int): The y-coordinate of the center of the square.
- box_size (int): The size of the square box to draw.
- color (tuple, optional): The color of the square in BGR format.
- thickness (int, optional): The thickness of the square border.
Returns:
np.ndarray: The frame with the drawn square.
616 def capture_named_region(self, current_frame: np.ndarray, name: str) -> np.ndarray: 617 """ 618 Captures a named region from the current frame. 619 620 Args: 621 current_frame (np.ndarray): The current frame from the emulator. 622 name (str): The name of the region to capture. 623 624 Returns: 625 np.ndarray: The captured region. 626 """ 627 if name not in self.named_screen_regions: 628 log_error(f"Named screen region {name} not found.", self._parameters) 629 region = self.named_screen_regions[name] 630 x, y, w, h = region.start_x, region.start_y, region.width, region.height 631 return self.capture_box(current_frame, x, y, w, h)
Captures a named region from the current frame.
Arguments:
- current_frame (np.ndarray): The current frame from the emulator.
- name (str): The name of the region to capture.
Returns:
np.ndarray: The captured region.
633 def compare_named_region_against_target( 634 self, current_frame: np.ndarray, name: str, strict_shape: bool = True 635 ) -> float: 636 """ 637 Computes the Absolute Error (AE) between a named region from the current frame and its target image. 638 639 Args: 640 current_frame (np.ndarray): The current frame from the emulator. 641 name (str): The name of the region to compare. 642 strict_shape (bool, optional): Whether to error out if the array shapes do not match. 643 Returns: 644 float: The Absolute Error (AE) between the named region and its target image. 645 """ 646 if name not in self.named_screen_regions: 647 log_error(f"Named screen region {name} not found.", self._parameters) 648 region = self.named_screen_regions[name] 649 captured_region = self.capture_named_region(current_frame, name) 650 return region.compare_against_target(captured_region, strict_shape)
Computes the Absolute Error (AE) between a named region from the current frame and its target image.
Arguments:
- current_frame (np.ndarray): The current frame from the emulator.
- name (str): The name of the region to compare.
- strict_shape (bool, optional): Whether to error out if the array shapes do not match.
Returns:
float: The Absolute Error (AE) between the named region and its target image.
652 def named_region_matches_target(self, current_frame: np.ndarray, name: str) -> bool: 653 """ 654 Compares a named region from the current frame to its target image using Absolute Error (AE). 655 656 Args: 657 current_frame (np.ndarray): The current frame from the emulator. 658 name (str): The name of the region to compare. 659 Returns: 660 bool: True if the region matches the target image, False otherwise. 661 """ 662 if name not in self.named_screen_regions: 663 log_error(f"Named screen region {name} not found.", self._parameters) 664 region = self.named_screen_regions[name] 665 captured_region = self.capture_named_region(current_frame, name) 666 return region.matches_target(captured_region)
Compares a named region from the current frame to its target image using Absolute Error (AE).
Arguments:
- current_frame (np.ndarray): The current frame from the emulator.
- name (str): The name of the region to compare.
Returns:
bool: True if the region matches the target image, False otherwise.
668 def compare_named_region_against_multi_target( 669 self, 670 current_frame: np.ndarray, 671 name: str, 672 target_name: str, 673 strict_shape: bool = True, 674 ) -> float: 675 """ 676 Computes the Absolute Error (AE) between a named region from the current frame and one of its multiple target images. 677 678 Args: 679 current_frame (np.ndarray): The current frame from the emulator. 680 name (str): The name of the region to compare. 681 target_name (str): The name of the target image to compare against. 682 strict_shape (bool, optional): Whether to error out if the array shapes do not match. 683 Returns: 684 float: The Absolute Error (AE) between the named region and the specified target image. 685 """ 686 if name not in self.named_screen_regions: 687 log_error(f"Named screen region {name} not found.", self._parameters) 688 region = self.named_screen_regions[name] 689 captured_region = self.capture_named_region(current_frame, name) 690 return region.compare_against_multi_target( 691 target_name, captured_region, strict_shape 692 )
Computes the Absolute Error (AE) between a named region from the current frame and one of its multiple target images.
Arguments:
- current_frame (np.ndarray): The current frame from the emulator.
- name (str): The name of the region to compare.
- target_name (str): The name of the target image to compare against.
- strict_shape (bool, optional): Whether to error out if the array shapes do not match.
Returns:
float: The Absolute Error (AE) between the named region and the specified target image.
694 def named_region_matches_multi_target( 695 self, current_frame: np.ndarray, name: str, target_name: str 696 ) -> bool: 697 """ 698 Compares a named region from the current frame to one of its multiple target images using Absolute Error (AE). 699 700 Args: 701 current_frame (np.ndarray): The current frame from the emulator. 702 name (str): The name of the region to compare. 703 target_name (str): The name of the target image to compare against. 704 Returns: 705 bool: True if the region matches the specified target image, False otherwise. 706 """ 707 if name not in self.named_screen_regions: 708 log_error(f"Named screen region {name} not found.", self._parameters) 709 region = self.named_screen_regions[name] 710 captured_region = self.capture_named_region(current_frame, name) 711 return region.matches_multi_target(target_name, captured_region)
Compares a named region from the current frame to one of its multiple target images using Absolute Error (AE).
Arguments:
- current_frame (np.ndarray): The current frame from the emulator.
- name (str): The name of the region to compare.
- target_name (str): The name of the target image to compare against.
Returns:
bool: True if the region matches the specified target image, False otherwise.
713 def draw_named_region( 714 self, 715 current_frame: np.ndarray, 716 name: str, 717 color: tuple = (0, 0, 0), 718 thickness: int = 1, 719 ) -> np.ndarray: 720 """ 721 Draws a named region on the current frame. 722 723 Args: 724 current_frame (np.ndarray): The current frame from the emulator. 725 name (str): The name of the region to draw. 726 color (tuple, optional): The color of the rectangle in BGR format. 727 thickness (int, optional): The thickness of the rectangle border. 728 729 Returns: 730 np.ndarray: The frame with the drawn rectangle. 731 """ 732 if name not in self.named_screen_regions: 733 log_error(f"Named screen region {name} not found.", self._parameters) 734 region = self.named_screen_regions[name] 735 x, y, w, h = region.start_x, region.start_y, region.width, region.height 736 return self.draw_box(current_frame, x, y, w, h, color, thickness)
Draws a named region on the current frame.
Arguments:
- current_frame (np.ndarray): The current frame from the emulator.
- name (str): The name of the region to draw.
- color (tuple, optional): The color of the rectangle in BGR format.
- thickness (int, optional): The thickness of the rectangle border.
Returns:
np.ndarray: The frame with the drawn rectangle.
738 @staticmethod 739 def draw_grid_overlay( 740 current_frame: np.ndarray, grid_skip: int = 16, x_offset=0, y_offset=-2 741 ) -> np.ndarray: 742 """ 743 Draws a grid overlay on the current frame for easier region identification. 744 Args: 745 current_frame (np.ndarray): The current frame from the emulator. 746 grid_skip (int, optional): The number of pixels between grid lines. 747 x_offset (int, optional): The x-offset to apply when drawing the grid. 748 y_offset (int, optional): The y-offset to apply when drawing the grid. 749 Returns: 750 np.ndarray: The frame with the grid overlay. 751 """ 752 frame_with_grid = current_frame.copy() 753 cv2 = import_cv2(None) 754 for x in range(0, current_frame.shape[1], grid_skip): 755 cv2.line( 756 frame_with_grid, 757 (x + x_offset, 0), 758 (x + x_offset, current_frame.shape[0]), 759 (0, 0, 255), 760 1, 761 lineType=cv2.LINE_AA, 762 ) 763 for y in range(0, current_frame.shape[0], grid_skip): 764 cv2.line( 765 frame_with_grid, 766 (0, y + y_offset), 767 (current_frame.shape[1], y + y_offset), 768 (0, 0, 255), 769 1, 770 lineType=cv2.LINE_AA, 771 ) 772 return frame_with_grid
Draws a grid overlay on the current frame for easier region identification.
Arguments:
- current_frame (np.ndarray): The current frame from the emulator.
- grid_skip (int, optional): The number of pixels between grid lines.
- x_offset (int, optional): The x-offset to apply when drawing the grid.
- y_offset (int, optional): The y-offset to apply when drawing the grid.
Returns:
np.ndarray: The frame with the grid overlay.
774 @staticmethod 775 def capture_grid_cells( 776 current_frame: np.ndarray, 777 *, 778 quadrant: str = None, 779 grid_skip: int = 16, 780 x_offset=0, 781 y_offset=-2, 782 ) -> Dict[Tuple[int, int], np.ndarray]: 783 """ 784 Captures all grid cells from the current frame based on the specified grid skip. 785 786 Example: 787 ```python 788 import matplotlib.pyplot as plt 789 # ... run the state_parser in an env, example in dev_play. 790 grid_cells = StateParser.capture_grid_cells(current_frame) 791 keep_keys = [(0, 0), (0, 1)] 792 new_cells = {} 793 for cell in keep_keys: 794 new_cells[cell] = grid_cells[cell] 795 grid_cells = new_cells 796 drawn_frame = self.state_parser.reform_image(grid_cells) 797 quadrants = self.state_parser.get_quadrant_frame(grid_cells=grid_cells) 798 plt.imshow(drawn_frame[:, :, 0], cmap="gray") 799 plt.title(f"Full Screen with Grid Overlay") 800 plt.show() 801 merged = self.state_parser.reform_image(grid_cells) 802 plt.imshow(merged[:, :, 0], cmap="gray") 803 plt.show() 804 ``` 805 806 :param current_frame: An emulator frame. 807 :type current_frame: np.ndarray 808 :param quadrant: If specified, only captures cells in the given quadrant ('TL', 'TR', 'BL', 'BR'). 809 :type quadrant: str 810 :param grid_skip: The number of pixels between grid lines. 811 :type grid_skip: int 812 :param x_offset: The x-offset to apply when capturing cells. 813 :param y_offset: The y-offset to apply when capturing cells. 814 :return: A dictionary mapping grid cell coordinates to their captured images. 815 The grid cells are with the central cell as (0,0) 816 :rtype: Dict[Tuple[int, int], ndarray[_AnyShape, dtype[Any]]] 817 """ 818 if quadrant is not None: 819 if quadrant.lower() not in ["tl", "tr", "bl", "br"]: 820 log_error( 821 f"Invalid quadrant: {quadrant}. Must be one of 'TL', 'TR', 'BL', 'BR'", 822 ) 823 cells = {} 824 if x_offset != 0: 825 x_iter = [-x_offset] + list(range(0, current_frame.shape[1], grid_skip)) 826 else: 827 x_iter = list(range(0, current_frame.shape[1], grid_skip)) 828 if y_offset != 0: 829 y_iter = [-y_offset] + list(range(0, current_frame.shape[0], grid_skip)) 830 else: 831 y_iter = list(range(0, current_frame.shape[0], grid_skip)) 832 833 def x_ind(x): 834 index = x_iter.index(x) 835 return (index - (len(x_iter)) // 2) + 1 * (x_offset == 0) 836 837 def y_ind(y): 838 index = y_iter.index(y) 839 return -(index - len(y_iter) // 2) + 1 * (y_offset == 0) 840 841 for x in x_iter: 842 for y in y_iter: 843 x_cell = x_ind(x) 844 y_cell = y_ind(y) 845 if quadrant is not None: 846 if quadrant.lower() == "tl" and (x_cell > 0 or y_cell < 0): 847 continue 848 elif quadrant.lower() == "tr" and (x_cell < 0 or y_cell < 0): 849 continue 850 elif quadrant.lower() == "bl" and (x_cell > 0 or y_cell > 0): 851 continue 852 elif quadrant.lower() == "br" and (x_cell < 0 or y_cell > 0): 853 continue 854 cell_image = StateParser.capture_box( 855 current_frame, x + x_offset, y + y_offset, grid_skip, grid_skip 856 ) 857 cells[(x_cell, y_cell)] = cell_image 858 return cells
Captures all grid cells from the current frame based on the specified grid skip.
Example:
import matplotlib.pyplot as plt
# ... run the state_parser in an env, example in dev_play.
grid_cells = StateParser.capture_grid_cells(current_frame)
keep_keys = [(0, 0), (0, 1)]
new_cells = {}
for cell in keep_keys:
new_cells[cell] = grid_cells[cell]
grid_cells = new_cells
drawn_frame = self.state_parser.reform_image(grid_cells)
quadrants = self.state_parser.get_quadrant_frame(grid_cells=grid_cells)
plt.imshow(drawn_frame[:, :, 0], cmap="gray")
plt.title(f"Full Screen with Grid Overlay")
plt.show()
merged = self.state_parser.reform_image(grid_cells)
plt.imshow(merged[:, :, 0], cmap="gray")
plt.show()
Parameters
- current_frame: An emulator frame.
- quadrant: If specified, only captures cells in the given quadrant ('TL', 'TR', 'BL', 'BR').
- grid_skip: The number of pixels between grid lines.
- x_offset: The x-offset to apply when capturing cells.
- y_offset: The y-offset to apply when capturing cells.
Returns
A dictionary mapping grid cell coordinates to their captured images. The grid cells are with the central cell as (0,0)
860 @staticmethod 861 def reform_image(grid_cells: Dict[Tuple[int, int], np.ndarray]) -> np.ndarray: 862 """ 863 Reform the image from grid cells back into a single image. 864 Expects the grid_cells to correspond to a rectangle. 865 Args: 866 grid_cells (Dict[Tuple[int, int], np.ndarray]): A dictionary mapping (x, y) coordinates to image cells. 867 868 Returns: 869 np.ndarray: The reformed image. 870 """ 871 coords = grid_cells.keys() 872 if len(coords) == 1: 873 return list(grid_cells.values())[0] 874 xs = list(set([coord[0] for coord in coords])) 875 ys = list(set([coord[1] for coord in coords])) 876 xs.sort() 877 ys.sort() 878 rows = [] 879 for y in ys: 880 row_cells = [] 881 for x in xs: 882 row_cells.append(grid_cells[(x, y)]) 883 row_image = np.concatenate(row_cells, axis=1) 884 rows.append(row_image) 885 new_rows = [] 886 887 if len(rows) == 1: 888 new_rows.append(rows[0]) 889 else: 890 # This part is super hacky. 891 # Sometimes, the last and second last row are the exact same. In that case, skip the last row. I don't know man. 892 # show_frames(rows) 893 is_same = rows[-1].shape != rows[-2].shape 894 back_offset = 2 if is_same else 1 895 for item in range(len(rows) - back_offset, -1, -1): 896 new_rows.append(rows[item]) 897 full_image = np.concatenate(new_rows, axis=0) 898 return full_image
Reform the image from grid cells back into a single image. Expects the grid_cells to correspond to a rectangle.
Arguments:
- grid_cells (Dict[Tuple[int, int], np.ndarray]): A dictionary mapping (x, y) coordinates to image cells.
Returns:
np.ndarray: The reformed image.
900 def get_quadrant_frame( 901 self, grid_cells: Dict[Tuple[int, int], np.ndarray] = None 902 ) -> Dict[str, Dict[str, Union[np.ndarray, Dict[Tuple[int, int], np.ndarray]]]]: 903 """ 904 Divides the current frame or subframe into quadrants and returns groups of quadrants 905 906 :param grid_cells: Subset of grid cells to split. Must be a rectangular box in (x, y) space. 907 :type grid_cells: Dict[Tuple[int, int], np.ndarray] 908 :return: A dictionary where the keys are quadrant keys [tr, tl, br, bl] and values are: 909 910 - screen: which maps to the single numpy array representing that quadrant as a screen 911 - cells: A dictionary mapping cell grids to the specific screen region as numpy arrays. 912 :rtype: Dict[str, Dict[str, Union[np.ndarray, Dict[Tuple[int, int], np.ndarray]]]] 913 """ 914 if grid_cells is None: 915 grid_cells = self.capture_grid_cells(self.get_current_frame()) 916 coords = grid_cells.keys() 917 xs = list(set([coord[0] for coord in coords])) 918 ys = list(set([coord[1] for coord in coords])) 919 xs.sort() 920 ys.sort() 921 mid_x = xs[len(xs) // 2] 922 mid_y = ys[len(ys) // 2] 923 quadrants = { 924 "tl": {"screen": None, "cells": {}}, 925 "tr": {"screen": None, "cells": {}}, 926 "bl": {"screen": None, "cells": {}}, 927 "br": {"screen": None, "cells": {}}, 928 } 929 lower_x = [x for x in xs if x < mid_x] 930 higher_x = [x for x in xs if x >= mid_x] 931 lower_y = [y for y in ys if y < mid_y] 932 higher_y = [y for y in ys if y >= mid_y] 933 for x in lower_x: 934 for y in higher_y: 935 quadrants["tl"]["cells"][(x, y)] = grid_cells[(x, y)] 936 quadrants["tl"]["screen"] = self.reform_image(quadrants["tl"]["cells"]) 937 for x in higher_x: 938 for y in higher_y: 939 quadrants["tr"]["cells"][(x, y)] = grid_cells[(x, y)] 940 quadrants["tr"]["screen"] = self.reform_image(quadrants["tr"]["cells"]) 941 for x in lower_x: 942 for y in lower_y: 943 quadrants["bl"]["cells"][(x, y)] = grid_cells[(x, y)] 944 quadrants["bl"]["screen"] = self.reform_image(quadrants["bl"]["cells"]) 945 for x in higher_x: 946 for y in lower_y: 947 quadrants["br"]["cells"][(x, y)] = grid_cells[(x, y)] 948 quadrants["br"]["screen"] = self.reform_image(quadrants["br"]["cells"]) 949 return quadrants
Divides the current frame or subframe into quadrants and returns groups of quadrants
Parameters
- grid_cells: Subset of grid cells to split. Must be a rectangular box in (x, y) space.
Returns
A dictionary where the keys are quadrant keys [tr, tl, br, bl] and values are:
- screen: which maps to the single numpy array representing that quadrant as a screen - cells: A dictionary mapping cell grids to the specific screen region as numpy arrays.
951 def get_image_reference(self, reference_name: str) -> Image.Image: 952 """ 953 Gets an image reference from the loaded image references. 954 Args: 955 reference_name (str): The name of the image reference to load 956 Returns: 957 Image.Image: The loaded image reference. 958 """ 959 if reference_name not in self.image_references: 960 log_error( 961 f"Image reference {reference_name} not found. Available options: {self.image_references.keys()}. If you want to add an image reference, add a file to the image_references folder.", 962 self._parameters, 963 ) 964 return self.image_references[reference_name]
Gets an image reference from the loaded image references.
Arguments:
- reference_name (str): The name of the image reference to load
Returns:
Image.Image: The loaded image reference.
Abstract base class for parsing game state variables from the GameBoy emulator.
Inherited Members
- StateParser
- StateParser
- rom_data_path
- named_screen_regions
- image_references
- bit_count
- read_m
- read_bits
- read_bit
- read_m_bit
- get_raised_flags
- get_current_frame
- capture_box
- capture_square_centered
- draw_box
- draw_square_centered
- capture_named_region
- compare_named_region_against_target
- named_region_matches_target
- compare_named_region_against_multi_target
- named_region_matches_multi_target
- draw_named_region
- draw_grid_overlay
- capture_grid_cells
- reform_image
- get_quadrant_frame
- get_image_reference