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"
class NamedScreenRegion:
 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.

NamedScreenRegion( name: str, start_x: int, start_y: int, width: int, height: int, parameters: dict, *, target_path: Optional[str] = None, multi_target_paths: Optional[Dict[str, str]] = None)
 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.
name

Name of the screen region.

start_x

The starting x-coordinate of the region.

start_y

The starting y-coordinate of the region.

width

The width of the region.

height

The height of the region.

target_path

Path to npy file of a screen capture that we will be comparing this region against.

target: Optional[numpy.ndarray]

Numpy array of the target image for this region.

multi_target_paths
multi_targets

Dictionary of multiple target paths for this region.

def get_end_x(self) -> int:
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.

def get_end_y(self) -> int:
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.

def get_corners(self) -> Tuple[int, int, int, int]:
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.

def compare_against_target(self, reference: numpy.ndarray, strict_shape: bool = True) -> float:
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.

def compare_against_multi_target( self, target_name: str, reference: numpy.ndarray, strict_shape: bool = True) -> float:
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.

def matches_target( self, reference: numpy.ndarray, strict_shape: bool = True, epsilon=0.01) -> bool:
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.

def matches_multi_target( self, target_name: str, reference: numpy.ndarray, strict_shape: bool = True) -> bool:
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.

def matches_any_multi_target( self, target_names: List[str], reference: numpy.ndarray, strict_shape: bool = True) -> bool:
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.

class StateParser(abc.ABC):
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.

StateParser( pyboy, parameters, named_screen_regions: Optional[List[NamedScreenRegion]] = None)
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.
rom_data_path: str

Path to the rom data directory for the game variant.

named_screen_regions: dict[str, NamedScreenRegion]

Dictionary of NamedScreenRegion objects for easy access to specific screen regions.

image_references

Dictionary of image references loaded.

@staticmethod
def bit_count(bits: int) -> int:
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.

def read_m(self, addr: bytes) -> int:
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.

def read_bits(self, addr) -> str:
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.

def read_bit(self, addr, bit: int) -> bool:
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.

def read_m_bit(self, addr_bit: str) -> bool:
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

def get_raised_flags(self, item_dict: dict) -> set:
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.

def get_current_frame(self) -> numpy.ndarray:
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.

@staticmethod
def capture_box( current_frame: numpy.ndarray, start_x: int, start_y: int, width: int, height: int) -> numpy.ndarray:
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.

@staticmethod
def capture_square_centered( current_frame: numpy.ndarray, center_x: int, center_y: int, box_size: int) -> numpy.ndarray:
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.

@staticmethod
def draw_box( current_frame: numpy.ndarray, start_x: int, start_y: int, width: int, height: int, color: tuple = (0, 0, 0), thickness: int = 1) -> numpy.ndarray:
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.

@staticmethod
def draw_square_centered( current_frame: numpy.ndarray, center_x: int, center_y: int, box_size: int, color: tuple = (0, 0, 0), thickness: int = 1) -> numpy.ndarray:
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.

def capture_named_region(self, current_frame: numpy.ndarray, name: str) -> numpy.ndarray:
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.

def compare_named_region_against_target( self, current_frame: numpy.ndarray, name: str, strict_shape: bool = True) -> float:
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.

def named_region_matches_target(self, current_frame: numpy.ndarray, name: str) -> bool:
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.

def compare_named_region_against_multi_target( self, current_frame: numpy.ndarray, name: str, target_name: str, strict_shape: bool = True) -> float:
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.

def named_region_matches_multi_target(self, current_frame: numpy.ndarray, name: str, target_name: str) -> bool:
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.

def draw_named_region( self, current_frame: numpy.ndarray, name: str, color: tuple = (0, 0, 0), thickness: int = 1) -> numpy.ndarray:
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.

@staticmethod
def draw_grid_overlay( current_frame: numpy.ndarray, grid_skip: int = 16, x_offset=0, y_offset=-2) -> numpy.ndarray:
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.

@staticmethod
def capture_grid_cells( current_frame: numpy.ndarray, *, quadrant: str = None, grid_skip: int = 16, x_offset=0, y_offset=-2) -> Dict[Tuple[int, int], numpy.ndarray]:
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)

@staticmethod
def reform_image(grid_cells: Dict[Tuple[int, int], numpy.ndarray]) -> numpy.ndarray:
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.

def get_quadrant_frame( self, grid_cells: Dict[Tuple[int, int], numpy.ndarray] = None) -> Dict[str, Dict[str, Union[numpy.ndarray, Dict[Tuple[int, int], numpy.ndarray]]]]:
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.
def get_image_reference(self, reference_name: str) -> PIL.Image.Image:
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.

class DummyParser(StateParser):
975class DummyParser(StateParser):
976    def __repr__(self) -> str:
977        return "DummyParser"

Abstract base class for parsing game state variables from the GameBoy emulator.