gameboy_worlds.emulation.tracker

   1from gameboy_worlds.emulation.parser import StateParser
   2from gameboy_worlds.utils import (
   3    nested_dict_to_str,
   4    verify_parameters,
   5    log_info,
   6    log_error,
   7    log_warn,
   8    log_dict,
   9    show_frames,
  10)
  11
  12
  13import numpy as np
  14from typing import Optional, Type, Dict, Any, Tuple, List, Set
  15
  16from abc import ABC, abstractmethod
  17
  18EPSILON = 0.001
  19""" Default epsilon for frame change detection. """
  20
  21
  22class MetricGroup(ABC):
  23    """
  24    Abstract Base class for organizing related metrics.
  25
  26    ### Documentation Guidlines:
  27    Every subchild should document the following in their class docstrings:
  28    - Reports (List of keys that are present in the return dict of `report`)
  29    - Final Reports (List of keys that are present in the return dict of `report_final`)
  30
  31    """
  32
  33    NAME = "base"
  34    """ Name of the MetricGroup. """
  35
  36    REQUIRED_PARSER = StateParser
  37    """ The StateParser which implements the minimum required functionality for this MetricGroup to work. """
  38
  39    def __init__(self, state_parser: StateParser, parameters: dict):
  40        verify_parameters(parameters)
  41        if not issubclass(type(state_parser), self.REQUIRED_PARSER):
  42            log_error(
  43                f"StateParser of type {type(state_parser)} is not compatible with MetricGroup requiring {self.REQUIRED_PARSER}."
  44            )
  45        self.state_parser = state_parser
  46        """ An instance of the StateParser to parse game state variables. """
  47        self._parameters = parameters
  48        self.start()
  49        self.final_metrics: Dict[str, Any] = None
  50        """ Dictionary to store final metrics after environment close. """
  51
  52    def start(self):
  53        """
  54        Called once when environment starts.
  55        All subclasses should call super() AFTER initializing their own variables.
  56        Only variables that will persist across episodes should be initialized here.
  57        """
  58        self.reset(first=True)
  59
  60    @abstractmethod
  61    def reset(self, first: bool = False):
  62        """Called when environment resets.
  63
  64        Args:
  65            first (bool): Whether this is the first reset of the environment. If True, might need to aggregate metrics into running final totals.
  66        """
  67        raise NotImplementedError
  68
  69    @abstractmethod
  70    def close(self):
  71        """
  72        Called when environment closes. Good for computing summary stats.
  73
  74        Step will not be called after this.
  75        """
  76        raise NotImplementedError
  77
  78    @abstractmethod
  79    def step(self, current_frame: np.ndarray, recent_frames: Optional[np.ndarray]):
  80        """
  81        Called each environment step to update metrics.
  82        Args:
  83            current_frame (np.ndarray): The current frame rendered by the emulator.
  84            recent_frames (Optional[np.ndarray]): The stack of frames that were rendered during the last action. Shape is [n_frames, height, width, channels]. Can be None if rendering is disabled.
  85        """
  86        raise NotImplementedError
  87
  88    @abstractmethod
  89    def report(self) -> Dict[str, Any]:
  90        """
  91        Return metrics as dictionary for instantaneous variable tracking.
  92
  93        :return: Dictionary of metrics
  94        :rtype: Dict[str, Any]
  95        """
  96        raise NotImplementedError
  97
  98    @abstractmethod
  99    def report_final(self) -> dict:
 100        """
 101        Return metrics as dictionary for logging. Called at end of environment (before close).
 102        Will never be called before `self.close`.
 103
 104        :return: Dictionary of metrics
 105        :rtype: Dict[str, Any]
 106        """
 107        raise NotImplementedError
 108
 109    def log_info(self, message: str):
 110        """
 111        Logs with MetricGroup's name. Primarily for debugging.
 112        """
 113        log_info(f"[Metric({self.NAME})]: {message}", self._parameters)
 114
 115    def log_warn(self, message: str):
 116        """
 117        Logs with MetricGroup's name. Primarily for debugging.
 118        """
 119        log_warn(f"[Metric({self.NAME})]: {message}", self._parameters)
 120
 121    def log_report(self):
 122        """
 123        Logs the current metrics report with MetricGroup's name. Primarily for debugging.
 124        """
 125        log_info(f"Metric({self.NAME}):\n")
 126        log_dict(self.report(), parameters=self._parameters)
 127
 128
 129class CoreMetrics(MetricGroup):
 130    """
 131    Tracks basic metrics that are guaranteed to be available in state tracker reports for any and all games:
 132
 133    Reports:
 134    - `steps`: Number of steps taken in the episode.
 135    - `frame_changed`: Whether the frame has changed since the last step.
 136    - `current_frame`: The current frame.
 137    - `passed_frames`: All frames that have passed since the last step.
 138
 139    Final Reports:
 140    - `total_episodes`: Total number of episodes completed.
 141    - `average_steps_per_episode`: Average number of steps taken per episode.
 142    - `max_steps`: Maximum number of steps taken in any episode.
 143    - `min_steps`: Minimum number of steps taken in any episode.
 144    - `std_steps`: Standard deviation of steps taken across episodes.
 145
 146    """
 147
 148    NAME = "core"
 149
 150    def start(self):
 151        self.steps_per_episode = []
 152        """ List of steps taken in each episode. """
 153        super().start()
 154
 155    def reset(self, first=False):
 156        if not first:
 157            self.steps_per_episode.append(self.steps)
 158        else:
 159            self.steps = 0
 160            """ Number of steps taken in the episode. """
 161            self.previous_frame = None
 162            """ Previous frame for detecting changes. """
 163            self.current_frame = None
 164            """ Current frame. """
 165            self.frame_changed = True
 166            """ Whether the frame has changed at all since last step. """
 167            self.passed_frames = None
 168            """ Stack of frames since the last step """
 169
 170    def close(self):
 171        if len(self.steps_per_episode) > 0:
 172            total_episodes = len(self.steps_per_episode)
 173            average_steps = np.mean(self.steps_per_episode)
 174            max_steps = np.max(self.steps_per_episode)
 175            min_steps = np.min(self.steps_per_episode)
 176            std_steps = np.std(self.steps_per_episode)
 177        else:
 178            total_episodes = 0
 179            average_steps = 0.0
 180            max_steps = 0
 181            min_steps = 0
 182            std_steps = 0.0
 183        self.final_metrics = {
 184            "total_episodes": int(total_episodes),
 185            "average_steps_per_episode": float(average_steps),
 186            "max_steps": int(max_steps),
 187            "min_steps": int(min_steps),
 188            "std_steps": float(std_steps),
 189        }
 190
 191    def step(self, current_frame, recent_frames):
 192        self.steps += 1
 193        self.current_frame = current_frame
 194        self.passed_frames = recent_frames
 195        if self.previous_frame is None:
 196            self.previous_frame = current_frame
 197            self.frame_changed = True
 198        else:
 199            frame_changed = False
 200            comparison_frame = self.previous_frame
 201            if recent_frames is None:
 202                recent_frames = np.array([current_frame])
 203            for frame in recent_frames:
 204                if np.abs(frame - comparison_frame).mean() > EPSILON:
 205                    frame_changed = True
 206                else:
 207                    frame_changed = False
 208                    comparison_frame = frame
 209                if frame_changed:
 210                    break
 211            self.frame_changed = frame_changed
 212        self.previous_frame = current_frame
 213
 214    def report(self):
 215        """
 216        Provides the following metrics:
 217        - `steps`: Number of steps taken in the episode.
 218        - `frame_changed`: Whether the frame has changed since the last step.
 219        - `current_frame`: The current frame.
 220        - `passed_frames`: All frames that have passed since the last step.
 221        """
 222        return {
 223            "steps": self.steps,
 224            "frame_changed": self.frame_changed,
 225            "current_frame": self.current_frame,
 226            "passed_frames": self.passed_frames,
 227        }
 228
 229    def report_final(self):
 230        """
 231        Provides the following metrics:
 232        - `total_episodes`: Total number of episodes completed.
 233        - `average_steps_per_episode`: Average number of steps taken per episode.
 234        - `max_steps`: Maximum number of steps taken in any episode.
 235        - `min_steps`: Minimum number of steps taken in any episode.
 236        - `std_steps`: Standard deviation of steps taken across episodes.
 237        """
 238        return self.final_metrics
 239
 240
 241class OCRegionMetric(MetricGroup, ABC):
 242    """
 243    Watch particular screen regions and capture subscreens for OCR when possible. Does not actually perform OCR itself, but makes it easy to capture the relevant regions.
 244    Children implementing this must define self.kinds in `start()` and then call on `super().start()`.
 245
 246    Reports:
 247    - `ocr_regions`: A dictionary mapping kinds to captured regions that had OCR-eligible text detected in them. The keys are kinds of OCR regions, and the values are the stacks of captured screen regions as numpy arrays of shape (num_captures, height, width, channels).
 248    - `step`: The current step number. Useful for differentiating when multiple OCR texts were found in the same episode. You can typically safely ignore this.
 249
 250    Final Reports:
 251    - `ocr_regions`: A list of tuples for all steps where OCR was detected. Is in form: `List[Tuple[int, Dict[str, np.ndarray]]]` where the int is the step number and the Dict maps kinds to a stack of the captured screen region.
 252
 253    """
 254
 255    NAME = "ocr"
 256
 257    def start(self):
 258        """
 259        Assumes the child has initialized a dict called self.kinds which tracks the various kinds of OCR that could be done.
 260                self.kinds should be in the form: {kind: region_name} where region_name is the name of the region to OCR for that kind.
 261                Will track ocr captured region results in form of list of dictionaries where these kinds are keys.
 262        """
 263        super().start()
 264        if self.NAME != "ocr":
 265            log_error(
 266                "OCRMetric subclasses must have NAME equal to 'ocr' for the environment get_info() aggregation step to work.",
 267                self._parameters,
 268            )
 269        if not hasattr(self, "kinds"):
 270            log_error("OCRMetrics must declare self.kinds dictionary", self._parameters)
 271        elif not isinstance(self.kinds, dict):
 272            log_error("self.kinds must be a dictionary", self._parameters)
 273        self.kinds: dict
 274        for item in self.kinds:
 275            if not isinstance(item, str):
 276                log_error("self.kinds keys must be strings", self._parameters)
 277            region_info = self.kinds[item]
 278            if not isinstance(region_info, str):
 279                log_error(
 280                    "self.kinds values must be region names (strings)", self._parameters
 281                )
 282            if region_info not in self.state_parser.named_screen_regions:
 283                log_error(
 284                    f"OCR region name {region_info} not found in state parser named regions. Available options: {self.state_parser.named_screen_regions}",
 285                    self._parameters,
 286                )
 287
 288    @staticmethod
 289    def can_read_kind(self, frame: np.ndarray, kind: str) -> bool:
 290        """
 291        Checks if the frame has text for the given kind.
 292
 293        Args:
 294            frame (np.ndarray): The frame to check.
 295            kind (str): The kind of text to check for.
 296        """
 297        raise NotImplementedError
 298
 299    def reset(self, first=False):
 300        """
 301        ocr_regions will track a list of the form List[Tuple[int, Dict[str, np.ndarray]]]
 302        which is a list of (step_number, {kind: ocr_region}) dictionaries.
 303        """
 304        self.ocr_regions = []
 305        self.steps = 0
 306        self.prev_has_ocr = False
 307
 308    def step(self, current_frame: np.ndarray, recent_frames: Optional[np.ndarray]):
 309        all_frames = None
 310        if recent_frames is not None:
 311            all_frames = recent_frames  # Current frame is included in recent frames
 312        else:
 313            all_frames = np.array([current_frame])
 314        ocr_dict = {}
 315        # Aggregate results for all frames and separate per kind.
 316        for kind in self.kinds.keys():
 317            captured_frames = []
 318            for frame in all_frames:
 319                if self.can_read_kind(frame, kind):
 320                    captured_frames.append(
 321                        self.state_parser.capture_named_region(
 322                            current_frame=frame, name=self.kinds[kind]
 323                        )
 324                    )
 325            true_captured_frames = []  # remove duplicates for efficiency
 326            # add the later frame in case of overlap detection. This prefers more text for cases where text comes as a stream.
 327            if len(captured_frames) > 0:
 328                for i in range(len(captured_frames) - 1):
 329                    curr_frame = captured_frames[i]
 330                    next_frame = captured_frames[i + 1]
 331                    if np.abs(curr_frame - next_frame).mean() > EPSILON:
 332                        true_captured_frames.append(curr_frame)
 333                true_captured_frames.append(
 334                    captured_frames[-1]
 335                )  # always add the last frame.
 336                ocr_dict[kind] = np.array(true_captured_frames)
 337        if len(ocr_dict) > 0:
 338            self.ocr_regions.append((self.steps, ocr_dict))
 339            self.prev_has_ocr = True
 340        else:
 341            self.prev_has_ocr = False
 342        self.steps += 1
 343
 344    def report(self):
 345        """
 346        Reports just the previous step's OCR regions if any were found.
 347        Returns:
 348            dict: A dictionary containing the captured regions for focused OCR.
 349        """
 350        if self.prev_has_ocr:
 351            return {
 352                "ocr_regions": self.ocr_regions[-1][1],
 353                "step": self.ocr_regions[-1][0],
 354            }
 355        else:
 356            return {}
 357
 358    def report_final(self):
 359        """
 360        Reports all the OCR regions extracted in the episode.
 361        """
 362        return {"ocr_regions": self.ocr_regions}
 363
 364    def close(self):
 365        pass
 366
 367
 368class SubGoal(ABC):
 369    """
 370    Abstract class representing a subgoal for tracking progress towards a test goal. These are intermediate states that must be achieved on the way to the final test goal.
 371    By convention, the task goal state itself is *not* considered a subgoal, but rather the final goal that the subgoals lead towards.
 372    """
 373
 374    NAME = "placeholder"
 375    """ Name of the subgoal. """
 376
 377    def __init__(self):
 378        if self.NAME == "placeholder":
 379            log_error(
 380                "Subclasses of SubGoal must set a unique NAME class variable.",
 381            )
 382        self.completed = False
 383
 384    def check_completed(self, frames: np.ndarray, parser: StateParser) -> bool:
 385        """
 386        Checks whether the subgoal has been completed based on the given frames and state parser.
 387
 388        Args:
 389            frames (np.ndarray): The stack of frames to check for subgoal completion.
 390            parser (StateParser): The state parser to use for checking subgoal completion.
 391        Returns:
 392            bool: True if the subgoal is completed, False otherwise.
 393        """
 394        for frame in frames:
 395            if self._check_completed(frame, parser):
 396                return True
 397        return False
 398
 399    @abstractmethod
 400    def _check_completed(self, frame: np.ndarray, parser: StateParser) -> bool:
 401        """
 402        Checks whether the subgoal has been completed based on a single frame and the state parser.
 403
 404        Args:
 405            frames (np.ndarray): A single frame to check for subgoal completion.
 406            parser (StateParser): The state parser to use for checking subgoal completion.
 407        Returns:
 408            bool: True if the subgoal is completed, False otherwise.
 409        """
 410        pass
 411
 412
 413class DummySubGoal(SubGoal):
 414    """
 415    A dummy subgoal that is never completed. Useful for testing.
 416    """
 417
 418    NAME = "dummy_subgoal"
 419
 420    def _check_completed(self, frame: np.ndarray, parser: StateParser) -> bool:
 421        return False
 422
 423
 424class RegionMatchSubGoal(SubGoal, ABC):
 425    """
 426    A subgoal that is completed if a specific region matches a target. Can be used to track subgoals that require specific dialogue boxes to appear, etc.
 427    """
 428
 429    NAME = "placeholder"
 430    _NAMED_REGION: str = None
 431    _TARGET_NAME: str = None
 432
 433    def __init__(self):
 434        super().__init__()
 435        if self._NAMED_REGION is None or self._TARGET_NAME is None:
 436            log_error(
 437                "Subclasses of RegionMatchSubGoal must set _NAMED_REGION and _TARGET_NAME class variables.",
 438            )
 439
 440    def _check_completed(self, frame: np.ndarray, parser: StateParser) -> bool:
 441        matches = parser.named_region_matches_multi_target(
 442            frame, self._NAMED_REGION, self._TARGET_NAME
 443        )
 444        return matches
 445
 446
 447class SingleRegionMatchSubGoal(SubGoal, ABC):
 448    """
 449    A subgoal that is completed if a specific single region matches its target.
 450    """
 451
 452    NAME = "placeholder"
 453    _NAMED_REGION: str = None
 454
 455    def __init__(self):
 456        super().__init__()
 457        if self._NAMED_REGION is None:
 458            log_error(
 459                "Subclasses of SingleRegionMatchSubGoal must set _NAMED_REGION class variable.",
 460            )
 461
 462    def _check_completed(self, frame, parser):
 463        matches = parser.named_region_matches_target(frame, self._NAMED_REGION)
 464        return matches
 465
 466
 467class AnyRegionMatchSubGoal(SubGoal, ABC):
 468    """
 469    A subgoal that is completed if any of a list of specific regions matches their targets.
 470    """
 471
 472    NAME = "placeholder"
 473    _NAMED_REGIONS: List[str] = None
 474    _TARGET_NAMES: List[str] = None
 475
 476    def __init__(self):
 477        super().__init__()
 478        if (
 479            self._NAMED_REGIONS is None
 480            or self._TARGET_NAMES is None
 481            or len(self._NAMED_REGIONS) != len(self._TARGET_NAMES)
 482            or len(self._NAMED_REGIONS) == 0
 483        ):
 484            log_error(
 485                "Subclasses of AnyRegionMatchSubGoal must set _NAMED_REGIONS and _TARGET_NAMES class variables, and they must be of the same length non zero.",
 486            )
 487
 488    def _check_completed(self, frame: np.ndarray, parser: StateParser) -> bool:
 489        for named_region, target_name in zip(self._NAMED_REGIONS, self._TARGET_NAMES):
 490            matches = parser.named_region_matches_multi_target(
 491                frame, named_region, target_name
 492            )
 493            if matches:
 494                return True
 495        return False
 496
 497
 498class AnySingleRegionMatchSubGoal(SubGoal, ABC):
 499    """
 500    A subgoal that is completed if any of a list of specific regions matches their targets, where each region only has one target.
 501    """
 502
 503    NAME = "placeholder"
 504    _NAMED_REGIONS: List[str] = None
 505
 506    def __init__(self):
 507        super().__init__()
 508        if self._NAMED_REGIONS is None or len(self._NAMED_REGIONS) == 0:
 509            log_error(
 510                "Subclasses of AnySingleRegionMatchSubGoal must set _NAMED_REGIONS class variable, and it must be non empty.",
 511            )
 512
 513    def _check_completed(self, frame: np.ndarray, parser: StateParser) -> bool:
 514        for named_region in self._NAMED_REGIONS:
 515            matches = parser.named_region_matches_target(frame, named_region)
 516            if matches:
 517                return True
 518        return False
 519
 520
 521class SubGoalMetric(MetricGroup, ABC):
 522    """
 523    Tracks subgoal based progress towards a specific test goal.
 524    Subgoals are always sequential, i.e. it is impossible to complete subgoal n+1 without completing subgoal n first.
 525
 526    Reports:
 527    - `all`: A list of the names of all subgoals being tracked, regardless of completion status.
 528    - `completed`: A list of the names of the subgoals that have been completed.
 529
 530    Final Reports:
 531    - `reached_subgoals`: List of subgoals that were reached at any point during any episode.
 532    """
 533
 534    NAME = "subgoals"
 535    SUBGOALS: List[SubGoal] = []
 536    """ List of SubGoal classes representing the subgoals to be tracked. These should be defined in child classes. """
 537
 538    def start(self):
 539        if self.NAME != "subgoals":
 540            log_error(
 541                f"SubGoalMetric NAME must be 'subgoals', got '{self.NAME}'.",
 542                self._parameters,
 543            )
 544        if len(self.SUBGOALS) == 0:
 545            log_error(
 546                "SubGoalMetric requires at least one subgoal to be defined in the SUBGOALS class variable.",
 547                self._parameters,
 548            )
 549        self._subgoals: List[SubGoal] = []
 550        """ List of SubGoal instances representing the subgoals being tracked. """
 551        for subgoal_class in self.SUBGOALS:
 552            subgoal_instance: SubGoal = subgoal_class()
 553            self._subgoals.append(subgoal_instance)
 554        self._reached_subgoals: Set[str] = set()
 555        """ Set of subgoals that were reached at any point during any episode. """
 556        super().start()
 557
 558    def close(self):
 559        pass
 560
 561    def reset(self, first=False):
 562        if not first:
 563            for subgoal in self._subgoals:
 564                if subgoal.completed:
 565                    self._reached_subgoals.add(subgoal.NAME)
 566        for subgoal in self._subgoals:
 567            subgoal.completed = False
 568
 569    def step(self, current_frame: np.ndarray, recent_frames: Optional[np.ndarray]):
 570        all_frames = None
 571        if recent_frames is not None:
 572            all_frames = recent_frames  # Current frame is included in recent frames
 573        else:
 574            all_frames = np.array([current_frame])
 575        for subgoal in self._subgoals:
 576            if not subgoal.completed:
 577                completed = subgoal.check_completed(all_frames, self.state_parser)
 578                subgoal.completed = completed
 579
 580    def report(self):
 581        """
 582        Reports the names of the subgoals being tracked and which ones have been completed.
 583        Returns:
 584            dict: A dictionary containing the list of all subgoals and the list of completed subgoals.
 585        """
 586        return {
 587            "all": [subgoal.NAME for subgoal in self._subgoals],
 588            "completed": [
 589                subgoal.NAME for subgoal in self._subgoals if subgoal.completed
 590            ],
 591        }
 592
 593    def report_final(self):
 594        """
 595        Reports the names of the subgoals that were reached at any point during any episode.
 596        Returns:
 597            dict: A dictionary containing the list of reached subgoals.
 598        """
 599        return {"reached_subgoals": list(self._reached_subgoals)}
 600
 601
 602class DummySubGoalMetric(SubGoalMetric):
 603    """
 604    A dummy SubGoalMetric that tracks a single DummySubGoal. Useful for testing.
 605    """
 606
 607    SUBGOALS = [DummySubGoal]
 608
 609
 610def make_subgoal_metric_class(subgoals: List[Type[SubGoal]]) -> Type[SubGoalMetric]:
 611    """
 612    Factory function to create a SubGoalMetric class with the given subgoals and name.
 613
 614    Args:
 615        subgoals (List[Type[SubGoal]]): The list of SubGoal classes to track.
 616        name (str): The name of the SubGoalMetric class.
 617
 618    Returns:
 619        Type[SubGoalMetric]: A new SubGoalMetric class with the specified subgoals and name.
 620    """
 621    if len(subgoals) == 0:
 622        log_error("Must provide at least one subgoal to create a SubGoalMetric class.")
 623
 624    class CustomSubGoalMetric(SubGoalMetric):
 625        SUBGOALS = subgoals
 626
 627    return CustomSubGoalMetric
 628
 629
 630class TerminationTruncationMetric(MetricGroup, ABC):
 631    """
 632    Tracks whether the environment was terminated or truncated.
 633
 634    Reports:
 635    - `terminated`: Whether the environment was terminated.
 636    - `truncated`: Whether the environment was truncated.
 637
 638    Final Reports:
 639    - `episode_end_reason`: List of reasons for episode endings: "terminated", "truncated", or None (None will occur only if there is a bug that leads to a premature reset).
 640    """
 641
 642    NAME = "termination_truncation"
 643
 644    def start(self):
 645        super().start()
 646        if self.NAME != "termination_truncation":
 647            log_error(
 648                f"TerminationTruncationMetric NAME must be 'termination_truncation', got '{self.NAME}'.",
 649                self._parameters,
 650            )
 651        self.episode_end_reason = []
 652        """ List of reasons for episode: termination or truncation or None (None will occur only if there is a bug that leads to a premature reset). """
 653        self.terminated = False
 654        """ Whether the environment was terminated. """
 655        self.truncated = False
 656        """ Whether the environment was truncated. """
 657
 658    def reset(self, first=False):
 659        if not first:
 660            if self.terminated:
 661                self.episode_end_reason.append("terminated")
 662            elif self.truncated:
 663                self.episode_end_reason.append("truncated")
 664            else:
 665                self.episode_end_reason.append(None)
 666        self.terminated = False
 667        self.truncated = False
 668
 669    def close(self):
 670        pass
 671
 672    @abstractmethod
 673    def determine_truncated(
 674        self, current_frame: np.ndarray, recent_frames: Optional[np.ndarray]
 675    ) -> bool:
 676        """
 677        Determines whether the environment was truncated.
 678
 679        :param current_frame: The current frame rendered by the emulator.
 680        :type current_frame: np.ndarray
 681        :param recent_frames: The stack of frames that were rendered during the last action. Shape is [n_frames, height, width, channels]. Can be None if rendering is disabled.
 682        :type recent_frames: Optional[np.ndarray]
 683        :return: True if the environment was truncated, False otherwise.
 684        :rtype: bool
 685        """
 686        pass
 687
 688    @abstractmethod
 689    def determine_terminated(
 690        self, current_frame: np.ndarray, recent_frames: Optional[np.ndarray]
 691    ) -> bool:
 692        """
 693        Determines whether the environment was terminated.
 694
 695        :param current_frame: The current frame rendered by the emulator.
 696        :type current_frame: np.ndarray
 697        :param recent_frames: The stack of frames that were rendered during the last action. Shape is [n_frames, height, width, channels]. Can be None if rendering is disabled.
 698        :type recent_frames: Optional[np.ndarray]
 699        :return: True if the environment was terminated, False otherwise.
 700        :rtype: bool
 701        """
 702        pass
 703
 704    def step(self, current_frame: np.ndarray, recent_frames: Optional[np.ndarray]):
 705        """
 706        Determines whether the environment was terminated or truncated.
 707        """
 708        if self.terminated or self.truncated:
 709            return  # This should ideally not happen, because the environment should reset after termination or truncation.
 710        self.truncated = self.determine_truncated(current_frame, recent_frames)
 711        self.terminated = self.determine_terminated(current_frame, recent_frames)
 712
 713    def report(self):
 714        """
 715        Reports whether the environment was terminated or truncated.
 716        Returns:
 717            dict: A dictionary containing the termination and truncation status.
 718        """
 719        return {
 720            "terminated": self.terminated,
 721            "truncated": self.truncated,
 722        }
 723
 724    def report_final(self):
 725        """
 726        Reports the reasons for episode endings.
 727        Returns:
 728            dict: A dictionary containing the list of episode end reasons.
 729        """
 730        return {"episode_end_reason": self.episode_end_reason}
 731
 732
 733class TerminationMetric(TerminationTruncationMetric, ABC):
 734    def determine_truncated(
 735        self, current_frame: np.ndarray, recent_frames: Optional[np.ndarray]
 736    ) -> bool:
 737        return False
 738
 739
 740class StateTracker:
 741    """
 742    Tracks and provides API access to the game state / metrics over time and across episodes.
 743    The most hassle-free way to read from the StateTracker is to use the `report()` and `report_final()` methods to get nested dictionaries of all metrics tracked.
 744
 745    **Example Usage:**
 746
 747    ```python
 748    import numpy as np
 749    from gameboy_worlds import get_pokemon_emulator
 750    emulator = get_pokemon_emulator(variant="pokemon_red")
 751
 752    # We can access the StateTracker via the emulator
 753    state_tracker = emulator.state_tracker
 754
 755    # Run a random action on the emulator
 756    emulator.reset()
 757    allowed_actions = list(LowLevelActions)
 758    action = np.random.choice(allowed_actions)
 759    _, _ = emulator.step(action) # also updates the StateTracker internally
 760    # We can access the current episode metrics via the StateTracker
 761    episode_metrics = state_tracker.report() # access all of them as a nested dict
 762    specific_metric = state_tracker.get_episode_metric(("core", "steps")) # access specific metrics
 763
 764    # If we reset the emulator, the StateTracker will reset its inter-episode metrics as well
 765    emulator.reset()
 766    action = np.random.choice(allowed_actions)
 767    _, _ = emulator.step(action)
 768    emulator.close() # StateTracker will finalize its metrics internally
 769    final_metrics = state_tracker.report_final() # access all of them as a nested dict
 770    specific_final_metric = state_tracker.get_final_metric(("core", "average_steps_per_episode")) # access specific final metrics
 771    ```
 772    """
 773
 774    TERMINATION_TRUNCATION_METRIC: Type[TerminationTruncationMetric] = None
 775    """ The TerminationTruncationMetric class to use for tracking termination and truncation. If None, no such metric will be tracked. """
 776
 777    SUBGOAL_METRIC: Type[SubGoalMetric] = None
 778    """ The SubGoalMetric class to use for tracking subgoal progress. If None, no such metric will be tracked. """
 779
 780    def __init__(
 781        self,
 782        state_parser: StateParser,
 783        parameters: dict,
 784    ):
 785        """
 786        Initializes the StateTracker.
 787        Args:
 788            state_parser (StateParser): An instance of the StateParser to parse game state variables.
 789            parameters (dict): A dictionary of parameters for configuration.
 790        """
 791        verify_parameters(parameters)
 792        self.state_parser = state_parser
 793        """ An instance of the StateParser to parse game state variables. """
 794        self._parameters = parameters
 795        self.start()
 796        self.validate()
 797        if self.metric_classes[0] != CoreMetrics:
 798            log_error(
 799                "First metric class must be CoreMetrics. Make sure to call `super().start()` first in child class overrides of `start()`.",
 800                parameters,
 801            )
 802        self.metrics = {}
 803        """ Dictionary to store MetricGroup instances. """
 804        for metric_group_class in self.metric_classes:
 805            metric_group_instance: MetricGroup = metric_group_class(
 806                state_parser, parameters
 807            )
 808            self.metrics[metric_group_instance.NAME] = metric_group_instance
 809        self.episode_metrics: Dict[str, Dict[str, Any]] = {}
 810        """ Dictionary to store metrics running during episode. """
 811        self.final_metrics: Dict[str, Dict[str, Any]] = {}
 812
 813    def start(self):
 814        """
 815        Sets up the metrics for the tracker by creating the list `self.metric_classes`
 816
 817        Child classes must FIRST call super().start() and THEN set up their own metric classes.
 818        """
 819        self.metric_classes: List[Type[MetricGroup]] = [CoreMetrics]
 820        if self.TERMINATION_TRUNCATION_METRIC is not None:
 821            if not issubclass(
 822                self.TERMINATION_TRUNCATION_METRIC, TerminationTruncationMetric
 823            ):
 824                log_error(
 825                    "TERMINATION_TRUNCATION_METRIC must be a subclass of TerminationTruncationMetric.",
 826                    self._parameters,
 827                )
 828            self.metric_classes.append(self.TERMINATION_TRUNCATION_METRIC)
 829        if self.SUBGOAL_METRIC is not None:
 830            if not issubclass(self.SUBGOAL_METRIC, SubGoalMetric):
 831                log_error(
 832                    "SUBGOAL_METRIC must be a subclass of SubGoalMetric.",
 833                    self._parameters,
 834                )
 835            self.metric_classes.append(self.SUBGOAL_METRIC)
 836
 837    def validate(self):
 838        """
 839        Is meant to be called once after initialization to ensure that the tracker is valid.
 840        """
 841        pass
 842
 843    def reset(self):
 844        """
 845        Is called once per environment reset to reset any tracked metrics.
 846        """
 847        for metric_group in self.metrics.values():
 848            metric_group.reset()
 849        self.step()
 850
 851    def step(self, recent_frames: Optional[np.ndarray] = None):
 852        """
 853        Is called once per environment step to update any tracked metrics.
 854
 855        Args:
 856            recent_frames (Optional[np.ndarray]): The stack of frames that were rendered during the last action. Shape is [n_frames, height, width, channels]. Can be None if rendering is disabled.
 857            epsilon (float, optional): The threshold for considering a frame change.
 858        """
 859        current_frame = None
 860        if recent_frames is None:
 861            current_frame = self.state_parser.get_current_frame()
 862        else:
 863            current_frame = recent_frames[-1]
 864        self.episode_metrics = {}
 865        for metric_group in self.metrics.values():
 866            metric_group.step(current_frame, recent_frames)
 867            self.episode_metrics[metric_group.NAME] = metric_group.report()
 868
 869    def close(self):
 870        """
 871        Is called once when the environment is closed to finalize any tracked metrics.
 872        """
 873        for metric_group in self.metrics.values():
 874            metric_group.close()
 875        self.final_metrics = {
 876            name: mg.report_final() for name, mg in self.metrics.items()
 877        }
 878
 879    def report(self) -> Dict[str, Dict[str, Any]]:
 880        """
 881        Returns the current episode metrics.
 882
 883        :return: A nested dictionary containing the current episode metrics.
 884        :rtype: Dict[str, Dict[str, Any]]
 885        """
 886        return self.episode_metrics
 887
 888    def report_final(self) -> Dict[str, Dict[str, Any]]:
 889        """
 890        Returns the final metrics after environment close.
 891
 892        Returns:
 893            Dict[str, Dict[str, Any]]: A nested dictionary containing the final metrics.
 894        """
 895        return self.final_metrics
 896
 897    def _get_specific_metric(self, metrics_dict, key: Tuple[str, str]):
 898        if metrics_dict is None:
 899            log_error("No metrics available. Have you called step() or close()?")
 900        metric_group_name, metric_name = key
 901        if metric_group_name not in metrics_dict:
 902            log_error(
 903                f"Metric group {metric_group_name} not found in metrics. Available groups: {list(metrics_dict.keys())}"
 904            )
 905        if metric_name not in metrics_dict[metric_group_name]:
 906            log_error(
 907                f"Metric {metric_name} not found in metric group {metric_group_name}. Available metrics: {list(metrics_dict[metric_group_name].keys())}"
 908            )
 909        return metrics_dict[metric_group_name][metric_name]
 910
 911    def get_episode_metric(self, key: Tuple[str, str]):
 912        """
 913        Returns the metrics for a specific episode and metric group.
 914
 915        Does not give final metrics at any point.
 916
 917        :param key: A tuple of the form (metric_group_name, metric_name).
 918        :type key: Tuple[str, str]
 919        :return: The requested metric value
 920        :rtype: Any
 921        """
 922        return self._get_specific_metric(self.episode_metrics, key)
 923
 924    def get_final_metric(self, key: Tuple[str, str]):
 925        """
 926        Returns the final metrics for a specific metric group.
 927
 928        :param key: A tuple of the form (metric_group_name, metric_name).
 929        :type key: Tuple[str, str]
 930        :return: The requested final metric value
 931        :rtype: Any
 932        """
 933        return self._get_specific_metric(self.final_metrics, key)
 934
 935    def __repr__(self) -> str:
 936        metric_names = [mg.NAME for mg in self.metrics.values()]
 937        return f"<StateTracker, metrics=({', '.join(metric_names)})>"
 938
 939
 940class TestTrackerMixin:
 941    """
 942    Mixin class for testing trackers.
 943    Ensures that exactly one of the tracked metrics is a TerminationTruncationMetric.
 944    """
 945
 946    def validate(self):
 947        if not hasattr(self, "_parameters"):
 948            log_error("Parameters have not been set yet.")
 949        if self.TERMINATION_TRUNCATION_METRIC is None:
 950            log_error(
 951                "TestTrackerMixin requires a TerminationTruncationMetric to be set as TERMINATION_TRUNCATION_METRIC.",
 952                self._parameters,
 953            )
 954        if self.SUBGOAL_METRIC is None:
 955            log_error(
 956                "TestTrackerMixin requires a SubGoalMetric to be set as SUBGOAL_METRIC.",
 957                self._parameters,
 958            )
 959
 960
 961class RegionMatchTruncationMetric(TerminationTruncationMetric, ABC):
 962    """
 963    Truncates the episode if a specific region matches a target.
 964    Can be used to truncate episodes when specific dialogue boxes appear, etc.
 965    """
 966
 967    _TRUNCATION_NAMED_REGION = None
 968    _TRUNCATION_TARGET_NAME = None
 969
 970    def determine_truncated(self, current_frame, recent_frames):
 971        if (
 972            self._TRUNCATION_NAMED_REGION is None
 973            or self._TRUNCATION_TARGET_NAME is None
 974        ):
 975            log_error(
 976                "Must set _TRUNCATION_NAMED_REGION and _TRUNCATION_TARGET_NAME.",
 977                self._parameters,
 978            )
 979        all_frames = [current_frame]
 980        if recent_frames is not None:
 981            all_frames = recent_frames
 982        for frame in all_frames:
 983            matches = self.state_parser.named_region_matches_multi_target(
 984                frame,
 985                self._TRUNCATION_NAMED_REGION,
 986                self._TRUNCATION_TARGET_NAME,
 987            )
 988            if matches:
 989                return True
 990        return False
 991
 992
 993class RegionMatchTerminationMetric(TerminationTruncationMetric, ABC):
 994    """
 995    Terminates the episode if a specific region matches a target.
 996    Can be used to terminate episodes when specific dialogue boxes appear, etc.
 997    """
 998
 999    _TERMINATION_NAMED_REGION = None
1000    _TERMINATION_TARGET_NAME = None
1001
1002    def determine_terminated(self, current_frame, recent_frames):
1003        if (
1004            self._TERMINATION_NAMED_REGION is None
1005            or self._TERMINATION_TARGET_NAME is None
1006        ):
1007            log_error(
1008                "Must set _TERMINATION_NAMED_REGION and _TERMINATION_TARGET_NAME.",
1009                self._parameters,
1010            )
1011        all_frames = [current_frame]
1012        if recent_frames is not None:
1013            all_frames = recent_frames
1014        for frame in all_frames:
1015            matches = self.state_parser.named_region_matches_multi_target(
1016                frame,
1017                self._TERMINATION_NAMED_REGION,
1018                self._TERMINATION_TARGET_NAME,
1019            )
1020            if matches:
1021                return True
1022        return False
1023
1024
1025class RegionMatchTerminationOnlyMetric(TerminationMetric, ABC):
1026    """
1027    RegionMatchTerminationMetric with no truncation.
1028    No truncation.
1029    """
1030
1031    _TERMINATION_NAMED_REGION = None
1032    _TERMINATION_TARGET_NAME = None
1033
1034    def determine_terminated(self, current_frame, recent_frames):
1035        if (
1036            self._TERMINATION_NAMED_REGION is None
1037            or self._TERMINATION_TARGET_NAME is None
1038        ):
1039            log_error(
1040                "Must set _TERMINATION_NAMED_REGION and _TERMINATION_TARGET_NAME.",
1041                self._parameters,
1042            )
1043        all_frames = [current_frame]
1044        if recent_frames is not None:
1045            all_frames = recent_frames
1046        for frame in all_frames:
1047            matches = self.state_parser.named_region_matches_multi_target(
1048                frame,
1049                self._TERMINATION_NAMED_REGION,
1050                self._TERMINATION_TARGET_NAME,
1051            )
1052            if matches:
1053                return True
1054        return False
1055
1056class AnyRegionMatchTerminationMetric(TerminationMetric, ABC):
1057    """
1058    Terminates the episode if any of a list of specific regions matches their targets.
1059    No truncation.
1060    """
1061
1062    _NAMED_REGIONS: List[str] = None
1063    _TARGET_NAMES: List[str] = None
1064
1065    def __init__(self):
1066        super().__init__()
1067        if (
1068            self._NAMED_REGIONS is None
1069            or self._TARGET_NAMES is None
1070            or len(self._NAMED_REGIONS) != len(self._TARGET_NAMES)
1071            or len(self._NAMED_REGIONS) == 0
1072        ):
1073            log_error(
1074                "Subclasses of AnyRegionMatchTerminationMetric must set _NAMED_REGIONS and _TARGET_NAMES class variables, and they must be of the same length non zero.",
1075            )
1076
1077    def determine_terminated(self, current_frame, recent_frames):
1078        all_frames = [current_frame]
1079        if recent_frames is not None:
1080            all_frames = recent_frames
1081        for frame in all_frames:
1082            for named_region, target_name in zip(self._NAMED_REGIONS, self._TARGET_NAMES):
1083                if self.state_parser.named_region_matches_multi_target(
1084                    frame, named_region, target_name
1085                ):
1086                    return True
1087        return False
1088
1089
1090class RegionChangedTerminationMetric(TerminationTruncationMetric, ABC):
1091    """
1092    Terminates the episode if a specific named region changes significantly
1093    from its appearance at the start of the episode (on reset).
1094
1095    Useful for detecting pickups, stat changes, or any event that alters a
1096    HUD region without having a fixed reference capture.
1097
1098    Subclass and set:
1099        _CHANGED_NAMED_REGION: name of the NamedScreenRegion to monitor
1100        _CHANGE_MAE_THRESHOLD: MAE threshold above which the region is considered changed (default 10)
1101    """
1102
1103    _CHANGED_NAMED_REGION = None
1104    _CHANGE_MAE_THRESHOLD = 10
1105
1106    def reset(self, first=False):
1107        super().reset(first=first)
1108        self._region_baseline = None
1109
1110    def determine_truncated(self, current_frame, recent_frames):
1111        return False
1112
1113    def determine_terminated(self, current_frame, recent_frames):
1114        if self._CHANGED_NAMED_REGION is None:
1115            log_error("Must set _CHANGED_NAMED_REGION.", self._parameters)
1116        cropped = self.state_parser.capture_named_region(
1117            current_frame, self._CHANGED_NAMED_REGION
1118        )
1119        if self._region_baseline is None:
1120            self._region_baseline = cropped.copy()
1121            return False
1122        mae = np.abs(cropped.astype(float) - self._region_baseline.astype(float)).mean()
1123        return mae > self._CHANGE_MAE_THRESHOLD
EPSILON = 0.001

Default epsilon for frame change detection.

class MetricGroup(abc.ABC):
 23class MetricGroup(ABC):
 24    """
 25    Abstract Base class for organizing related metrics.
 26
 27    ### Documentation Guidlines:
 28    Every subchild should document the following in their class docstrings:
 29    - Reports (List of keys that are present in the return dict of `report`)
 30    - Final Reports (List of keys that are present in the return dict of `report_final`)
 31
 32    """
 33
 34    NAME = "base"
 35    """ Name of the MetricGroup. """
 36
 37    REQUIRED_PARSER = StateParser
 38    """ The StateParser which implements the minimum required functionality for this MetricGroup to work. """
 39
 40    def __init__(self, state_parser: StateParser, parameters: dict):
 41        verify_parameters(parameters)
 42        if not issubclass(type(state_parser), self.REQUIRED_PARSER):
 43            log_error(
 44                f"StateParser of type {type(state_parser)} is not compatible with MetricGroup requiring {self.REQUIRED_PARSER}."
 45            )
 46        self.state_parser = state_parser
 47        """ An instance of the StateParser to parse game state variables. """
 48        self._parameters = parameters
 49        self.start()
 50        self.final_metrics: Dict[str, Any] = None
 51        """ Dictionary to store final metrics after environment close. """
 52
 53    def start(self):
 54        """
 55        Called once when environment starts.
 56        All subclasses should call super() AFTER initializing their own variables.
 57        Only variables that will persist across episodes should be initialized here.
 58        """
 59        self.reset(first=True)
 60
 61    @abstractmethod
 62    def reset(self, first: bool = False):
 63        """Called when environment resets.
 64
 65        Args:
 66            first (bool): Whether this is the first reset of the environment. If True, might need to aggregate metrics into running final totals.
 67        """
 68        raise NotImplementedError
 69
 70    @abstractmethod
 71    def close(self):
 72        """
 73        Called when environment closes. Good for computing summary stats.
 74
 75        Step will not be called after this.
 76        """
 77        raise NotImplementedError
 78
 79    @abstractmethod
 80    def step(self, current_frame: np.ndarray, recent_frames: Optional[np.ndarray]):
 81        """
 82        Called each environment step to update metrics.
 83        Args:
 84            current_frame (np.ndarray): The current frame rendered by the emulator.
 85            recent_frames (Optional[np.ndarray]): The stack of frames that were rendered during the last action. Shape is [n_frames, height, width, channels]. Can be None if rendering is disabled.
 86        """
 87        raise NotImplementedError
 88
 89    @abstractmethod
 90    def report(self) -> Dict[str, Any]:
 91        """
 92        Return metrics as dictionary for instantaneous variable tracking.
 93
 94        :return: Dictionary of metrics
 95        :rtype: Dict[str, Any]
 96        """
 97        raise NotImplementedError
 98
 99    @abstractmethod
100    def report_final(self) -> dict:
101        """
102        Return metrics as dictionary for logging. Called at end of environment (before close).
103        Will never be called before `self.close`.
104
105        :return: Dictionary of metrics
106        :rtype: Dict[str, Any]
107        """
108        raise NotImplementedError
109
110    def log_info(self, message: str):
111        """
112        Logs with MetricGroup's name. Primarily for debugging.
113        """
114        log_info(f"[Metric({self.NAME})]: {message}", self._parameters)
115
116    def log_warn(self, message: str):
117        """
118        Logs with MetricGroup's name. Primarily for debugging.
119        """
120        log_warn(f"[Metric({self.NAME})]: {message}", self._parameters)
121
122    def log_report(self):
123        """
124        Logs the current metrics report with MetricGroup's name. Primarily for debugging.
125        """
126        log_info(f"Metric({self.NAME}):\n")
127        log_dict(self.report(), parameters=self._parameters)

Abstract Base class for organizing related metrics.

Documentation Guidlines:

Every subchild should document the following in their class docstrings:

  • Reports (List of keys that are present in the return dict of report)
  • Final Reports (List of keys that are present in the return dict of report_final)
NAME = 'base'

Name of the MetricGroup.

REQUIRED_PARSER = <class 'gameboy_worlds.emulation.parser.StateParser'>

The StateParser which implements the minimum required functionality for this MetricGroup to work.

state_parser

An instance of the StateParser to parse game state variables.

final_metrics: Dict[str, Any]

Dictionary to store final metrics after environment close.

def start(self):
53    def start(self):
54        """
55        Called once when environment starts.
56        All subclasses should call super() AFTER initializing their own variables.
57        Only variables that will persist across episodes should be initialized here.
58        """
59        self.reset(first=True)

Called once when environment starts. All subclasses should call super() AFTER initializing their own variables. Only variables that will persist across episodes should be initialized here.

@abstractmethod
def reset(self, first: bool = False):
61    @abstractmethod
62    def reset(self, first: bool = False):
63        """Called when environment resets.
64
65        Args:
66            first (bool): Whether this is the first reset of the environment. If True, might need to aggregate metrics into running final totals.
67        """
68        raise NotImplementedError

Called when environment resets.

Arguments:
  • first (bool): Whether this is the first reset of the environment. If True, might need to aggregate metrics into running final totals.
@abstractmethod
def close(self):
70    @abstractmethod
71    def close(self):
72        """
73        Called when environment closes. Good for computing summary stats.
74
75        Step will not be called after this.
76        """
77        raise NotImplementedError

Called when environment closes. Good for computing summary stats.

Step will not be called after this.

@abstractmethod
def step( self, current_frame: numpy.ndarray, recent_frames: Optional[numpy.ndarray]):
79    @abstractmethod
80    def step(self, current_frame: np.ndarray, recent_frames: Optional[np.ndarray]):
81        """
82        Called each environment step to update metrics.
83        Args:
84            current_frame (np.ndarray): The current frame rendered by the emulator.
85            recent_frames (Optional[np.ndarray]): The stack of frames that were rendered during the last action. Shape is [n_frames, height, width, channels]. Can be None if rendering is disabled.
86        """
87        raise NotImplementedError

Called each environment step to update metrics.

Arguments:
  • current_frame (np.ndarray): The current frame rendered by the emulator.
  • recent_frames (Optional[np.ndarray]): The stack of frames that were rendered during the last action. Shape is [n_frames, height, width, channels]. Can be None if rendering is disabled.
@abstractmethod
def report(self) -> Dict[str, Any]:
89    @abstractmethod
90    def report(self) -> Dict[str, Any]:
91        """
92        Return metrics as dictionary for instantaneous variable tracking.
93
94        :return: Dictionary of metrics
95        :rtype: Dict[str, Any]
96        """
97        raise NotImplementedError

Return metrics as dictionary for instantaneous variable tracking.

Returns

Dictionary of metrics

@abstractmethod
def report_final(self) -> dict:
 99    @abstractmethod
100    def report_final(self) -> dict:
101        """
102        Return metrics as dictionary for logging. Called at end of environment (before close).
103        Will never be called before `self.close`.
104
105        :return: Dictionary of metrics
106        :rtype: Dict[str, Any]
107        """
108        raise NotImplementedError

Return metrics as dictionary for logging. Called at end of environment (before close). Will never be called before self.close.

Returns

Dictionary of metrics

def log_info(self, message: str):
110    def log_info(self, message: str):
111        """
112        Logs with MetricGroup's name. Primarily for debugging.
113        """
114        log_info(f"[Metric({self.NAME})]: {message}", self._parameters)

Logs with MetricGroup's name. Primarily for debugging.

def log_warn(self, message: str):
116    def log_warn(self, message: str):
117        """
118        Logs with MetricGroup's name. Primarily for debugging.
119        """
120        log_warn(f"[Metric({self.NAME})]: {message}", self._parameters)

Logs with MetricGroup's name. Primarily for debugging.

def log_report(self):
122    def log_report(self):
123        """
124        Logs the current metrics report with MetricGroup's name. Primarily for debugging.
125        """
126        log_info(f"Metric({self.NAME}):\n")
127        log_dict(self.report(), parameters=self._parameters)

Logs the current metrics report with MetricGroup's name. Primarily for debugging.

class CoreMetrics(MetricGroup):
130class CoreMetrics(MetricGroup):
131    """
132    Tracks basic metrics that are guaranteed to be available in state tracker reports for any and all games:
133
134    Reports:
135    - `steps`: Number of steps taken in the episode.
136    - `frame_changed`: Whether the frame has changed since the last step.
137    - `current_frame`: The current frame.
138    - `passed_frames`: All frames that have passed since the last step.
139
140    Final Reports:
141    - `total_episodes`: Total number of episodes completed.
142    - `average_steps_per_episode`: Average number of steps taken per episode.
143    - `max_steps`: Maximum number of steps taken in any episode.
144    - `min_steps`: Minimum number of steps taken in any episode.
145    - `std_steps`: Standard deviation of steps taken across episodes.
146
147    """
148
149    NAME = "core"
150
151    def start(self):
152        self.steps_per_episode = []
153        """ List of steps taken in each episode. """
154        super().start()
155
156    def reset(self, first=False):
157        if not first:
158            self.steps_per_episode.append(self.steps)
159        else:
160            self.steps = 0
161            """ Number of steps taken in the episode. """
162            self.previous_frame = None
163            """ Previous frame for detecting changes. """
164            self.current_frame = None
165            """ Current frame. """
166            self.frame_changed = True
167            """ Whether the frame has changed at all since last step. """
168            self.passed_frames = None
169            """ Stack of frames since the last step """
170
171    def close(self):
172        if len(self.steps_per_episode) > 0:
173            total_episodes = len(self.steps_per_episode)
174            average_steps = np.mean(self.steps_per_episode)
175            max_steps = np.max(self.steps_per_episode)
176            min_steps = np.min(self.steps_per_episode)
177            std_steps = np.std(self.steps_per_episode)
178        else:
179            total_episodes = 0
180            average_steps = 0.0
181            max_steps = 0
182            min_steps = 0
183            std_steps = 0.0
184        self.final_metrics = {
185            "total_episodes": int(total_episodes),
186            "average_steps_per_episode": float(average_steps),
187            "max_steps": int(max_steps),
188            "min_steps": int(min_steps),
189            "std_steps": float(std_steps),
190        }
191
192    def step(self, current_frame, recent_frames):
193        self.steps += 1
194        self.current_frame = current_frame
195        self.passed_frames = recent_frames
196        if self.previous_frame is None:
197            self.previous_frame = current_frame
198            self.frame_changed = True
199        else:
200            frame_changed = False
201            comparison_frame = self.previous_frame
202            if recent_frames is None:
203                recent_frames = np.array([current_frame])
204            for frame in recent_frames:
205                if np.abs(frame - comparison_frame).mean() > EPSILON:
206                    frame_changed = True
207                else:
208                    frame_changed = False
209                    comparison_frame = frame
210                if frame_changed:
211                    break
212            self.frame_changed = frame_changed
213        self.previous_frame = current_frame
214
215    def report(self):
216        """
217        Provides the following metrics:
218        - `steps`: Number of steps taken in the episode.
219        - `frame_changed`: Whether the frame has changed since the last step.
220        - `current_frame`: The current frame.
221        - `passed_frames`: All frames that have passed since the last step.
222        """
223        return {
224            "steps": self.steps,
225            "frame_changed": self.frame_changed,
226            "current_frame": self.current_frame,
227            "passed_frames": self.passed_frames,
228        }
229
230    def report_final(self):
231        """
232        Provides the following metrics:
233        - `total_episodes`: Total number of episodes completed.
234        - `average_steps_per_episode`: Average number of steps taken per episode.
235        - `max_steps`: Maximum number of steps taken in any episode.
236        - `min_steps`: Minimum number of steps taken in any episode.
237        - `std_steps`: Standard deviation of steps taken across episodes.
238        """
239        return self.final_metrics

Tracks basic metrics that are guaranteed to be available in state tracker reports for any and all games:

Reports:

  • steps: Number of steps taken in the episode.
  • frame_changed: Whether the frame has changed since the last step.
  • current_frame: The current frame.
  • passed_frames: All frames that have passed since the last step.

Final Reports:

  • total_episodes: Total number of episodes completed.
  • average_steps_per_episode: Average number of steps taken per episode.
  • max_steps: Maximum number of steps taken in any episode.
  • min_steps: Minimum number of steps taken in any episode.
  • std_steps: Standard deviation of steps taken across episodes.
NAME = 'core'

Name of the MetricGroup.

def start(self):
151    def start(self):
152        self.steps_per_episode = []
153        """ List of steps taken in each episode. """
154        super().start()

Called once when environment starts. All subclasses should call super() AFTER initializing their own variables. Only variables that will persist across episodes should be initialized here.

def reset(self, first=False):
156    def reset(self, first=False):
157        if not first:
158            self.steps_per_episode.append(self.steps)
159        else:
160            self.steps = 0
161            """ Number of steps taken in the episode. """
162            self.previous_frame = None
163            """ Previous frame for detecting changes. """
164            self.current_frame = None
165            """ Current frame. """
166            self.frame_changed = True
167            """ Whether the frame has changed at all since last step. """
168            self.passed_frames = None
169            """ Stack of frames since the last step """

Called when environment resets.

Arguments:
  • first (bool): Whether this is the first reset of the environment. If True, might need to aggregate metrics into running final totals.
def close(self):
171    def close(self):
172        if len(self.steps_per_episode) > 0:
173            total_episodes = len(self.steps_per_episode)
174            average_steps = np.mean(self.steps_per_episode)
175            max_steps = np.max(self.steps_per_episode)
176            min_steps = np.min(self.steps_per_episode)
177            std_steps = np.std(self.steps_per_episode)
178        else:
179            total_episodes = 0
180            average_steps = 0.0
181            max_steps = 0
182            min_steps = 0
183            std_steps = 0.0
184        self.final_metrics = {
185            "total_episodes": int(total_episodes),
186            "average_steps_per_episode": float(average_steps),
187            "max_steps": int(max_steps),
188            "min_steps": int(min_steps),
189            "std_steps": float(std_steps),
190        }

Called when environment closes. Good for computing summary stats.

Step will not be called after this.

def step(self, current_frame, recent_frames):
192    def step(self, current_frame, recent_frames):
193        self.steps += 1
194        self.current_frame = current_frame
195        self.passed_frames = recent_frames
196        if self.previous_frame is None:
197            self.previous_frame = current_frame
198            self.frame_changed = True
199        else:
200            frame_changed = False
201            comparison_frame = self.previous_frame
202            if recent_frames is None:
203                recent_frames = np.array([current_frame])
204            for frame in recent_frames:
205                if np.abs(frame - comparison_frame).mean() > EPSILON:
206                    frame_changed = True
207                else:
208                    frame_changed = False
209                    comparison_frame = frame
210                if frame_changed:
211                    break
212            self.frame_changed = frame_changed
213        self.previous_frame = current_frame

Called each environment step to update metrics.

Arguments:
  • current_frame (np.ndarray): The current frame rendered by the emulator.
  • recent_frames (Optional[np.ndarray]): The stack of frames that were rendered during the last action. Shape is [n_frames, height, width, channels]. Can be None if rendering is disabled.
def report(self):
215    def report(self):
216        """
217        Provides the following metrics:
218        - `steps`: Number of steps taken in the episode.
219        - `frame_changed`: Whether the frame has changed since the last step.
220        - `current_frame`: The current frame.
221        - `passed_frames`: All frames that have passed since the last step.
222        """
223        return {
224            "steps": self.steps,
225            "frame_changed": self.frame_changed,
226            "current_frame": self.current_frame,
227            "passed_frames": self.passed_frames,
228        }

Provides the following metrics:

  • steps: Number of steps taken in the episode.
  • frame_changed: Whether the frame has changed since the last step.
  • current_frame: The current frame.
  • passed_frames: All frames that have passed since the last step.
def report_final(self):
230    def report_final(self):
231        """
232        Provides the following metrics:
233        - `total_episodes`: Total number of episodes completed.
234        - `average_steps_per_episode`: Average number of steps taken per episode.
235        - `max_steps`: Maximum number of steps taken in any episode.
236        - `min_steps`: Minimum number of steps taken in any episode.
237        - `std_steps`: Standard deviation of steps taken across episodes.
238        """
239        return self.final_metrics

Provides the following metrics:

  • total_episodes: Total number of episodes completed.
  • average_steps_per_episode: Average number of steps taken per episode.
  • max_steps: Maximum number of steps taken in any episode.
  • min_steps: Minimum number of steps taken in any episode.
  • std_steps: Standard deviation of steps taken across episodes.
class OCRegionMetric(MetricGroup, abc.ABC):
242class OCRegionMetric(MetricGroup, ABC):
243    """
244    Watch particular screen regions and capture subscreens for OCR when possible. Does not actually perform OCR itself, but makes it easy to capture the relevant regions.
245    Children implementing this must define self.kinds in `start()` and then call on `super().start()`.
246
247    Reports:
248    - `ocr_regions`: A dictionary mapping kinds to captured regions that had OCR-eligible text detected in them. The keys are kinds of OCR regions, and the values are the stacks of captured screen regions as numpy arrays of shape (num_captures, height, width, channels).
249    - `step`: The current step number. Useful for differentiating when multiple OCR texts were found in the same episode. You can typically safely ignore this.
250
251    Final Reports:
252    - `ocr_regions`: A list of tuples for all steps where OCR was detected. Is in form: `List[Tuple[int, Dict[str, np.ndarray]]]` where the int is the step number and the Dict maps kinds to a stack of the captured screen region.
253
254    """
255
256    NAME = "ocr"
257
258    def start(self):
259        """
260        Assumes the child has initialized a dict called self.kinds which tracks the various kinds of OCR that could be done.
261                self.kinds should be in the form: {kind: region_name} where region_name is the name of the region to OCR for that kind.
262                Will track ocr captured region results in form of list of dictionaries where these kinds are keys.
263        """
264        super().start()
265        if self.NAME != "ocr":
266            log_error(
267                "OCRMetric subclasses must have NAME equal to 'ocr' for the environment get_info() aggregation step to work.",
268                self._parameters,
269            )
270        if not hasattr(self, "kinds"):
271            log_error("OCRMetrics must declare self.kinds dictionary", self._parameters)
272        elif not isinstance(self.kinds, dict):
273            log_error("self.kinds must be a dictionary", self._parameters)
274        self.kinds: dict
275        for item in self.kinds:
276            if not isinstance(item, str):
277                log_error("self.kinds keys must be strings", self._parameters)
278            region_info = self.kinds[item]
279            if not isinstance(region_info, str):
280                log_error(
281                    "self.kinds values must be region names (strings)", self._parameters
282                )
283            if region_info not in self.state_parser.named_screen_regions:
284                log_error(
285                    f"OCR region name {region_info} not found in state parser named regions. Available options: {self.state_parser.named_screen_regions}",
286                    self._parameters,
287                )
288
289    @staticmethod
290    def can_read_kind(self, frame: np.ndarray, kind: str) -> bool:
291        """
292        Checks if the frame has text for the given kind.
293
294        Args:
295            frame (np.ndarray): The frame to check.
296            kind (str): The kind of text to check for.
297        """
298        raise NotImplementedError
299
300    def reset(self, first=False):
301        """
302        ocr_regions will track a list of the form List[Tuple[int, Dict[str, np.ndarray]]]
303        which is a list of (step_number, {kind: ocr_region}) dictionaries.
304        """
305        self.ocr_regions = []
306        self.steps = 0
307        self.prev_has_ocr = False
308
309    def step(self, current_frame: np.ndarray, recent_frames: Optional[np.ndarray]):
310        all_frames = None
311        if recent_frames is not None:
312            all_frames = recent_frames  # Current frame is included in recent frames
313        else:
314            all_frames = np.array([current_frame])
315        ocr_dict = {}
316        # Aggregate results for all frames and separate per kind.
317        for kind in self.kinds.keys():
318            captured_frames = []
319            for frame in all_frames:
320                if self.can_read_kind(frame, kind):
321                    captured_frames.append(
322                        self.state_parser.capture_named_region(
323                            current_frame=frame, name=self.kinds[kind]
324                        )
325                    )
326            true_captured_frames = []  # remove duplicates for efficiency
327            # add the later frame in case of overlap detection. This prefers more text for cases where text comes as a stream.
328            if len(captured_frames) > 0:
329                for i in range(len(captured_frames) - 1):
330                    curr_frame = captured_frames[i]
331                    next_frame = captured_frames[i + 1]
332                    if np.abs(curr_frame - next_frame).mean() > EPSILON:
333                        true_captured_frames.append(curr_frame)
334                true_captured_frames.append(
335                    captured_frames[-1]
336                )  # always add the last frame.
337                ocr_dict[kind] = np.array(true_captured_frames)
338        if len(ocr_dict) > 0:
339            self.ocr_regions.append((self.steps, ocr_dict))
340            self.prev_has_ocr = True
341        else:
342            self.prev_has_ocr = False
343        self.steps += 1
344
345    def report(self):
346        """
347        Reports just the previous step's OCR regions if any were found.
348        Returns:
349            dict: A dictionary containing the captured regions for focused OCR.
350        """
351        if self.prev_has_ocr:
352            return {
353                "ocr_regions": self.ocr_regions[-1][1],
354                "step": self.ocr_regions[-1][0],
355            }
356        else:
357            return {}
358
359    def report_final(self):
360        """
361        Reports all the OCR regions extracted in the episode.
362        """
363        return {"ocr_regions": self.ocr_regions}
364
365    def close(self):
366        pass

Watch particular screen regions and capture subscreens for OCR when possible. Does not actually perform OCR itself, but makes it easy to capture the relevant regions. Children implementing this must define self.kinds in start() and then call on super().start().

Reports:

  • ocr_regions: A dictionary mapping kinds to captured regions that had OCR-eligible text detected in them. The keys are kinds of OCR regions, and the values are the stacks of captured screen regions as numpy arrays of shape (num_captures, height, width, channels).
  • step: The current step number. Useful for differentiating when multiple OCR texts were found in the same episode. You can typically safely ignore this.

Final Reports:

  • ocr_regions: A list of tuples for all steps where OCR was detected. Is in form: List[Tuple[int, Dict[str, np.ndarray]]] where the int is the step number and the Dict maps kinds to a stack of the captured screen region.
NAME = 'ocr'

Name of the MetricGroup.

def start(self):
258    def start(self):
259        """
260        Assumes the child has initialized a dict called self.kinds which tracks the various kinds of OCR that could be done.
261                self.kinds should be in the form: {kind: region_name} where region_name is the name of the region to OCR for that kind.
262                Will track ocr captured region results in form of list of dictionaries where these kinds are keys.
263        """
264        super().start()
265        if self.NAME != "ocr":
266            log_error(
267                "OCRMetric subclasses must have NAME equal to 'ocr' for the environment get_info() aggregation step to work.",
268                self._parameters,
269            )
270        if not hasattr(self, "kinds"):
271            log_error("OCRMetrics must declare self.kinds dictionary", self._parameters)
272        elif not isinstance(self.kinds, dict):
273            log_error("self.kinds must be a dictionary", self._parameters)
274        self.kinds: dict
275        for item in self.kinds:
276            if not isinstance(item, str):
277                log_error("self.kinds keys must be strings", self._parameters)
278            region_info = self.kinds[item]
279            if not isinstance(region_info, str):
280                log_error(
281                    "self.kinds values must be region names (strings)", self._parameters
282                )
283            if region_info not in self.state_parser.named_screen_regions:
284                log_error(
285                    f"OCR region name {region_info} not found in state parser named regions. Available options: {self.state_parser.named_screen_regions}",
286                    self._parameters,
287                )

Assumes the child has initialized a dict called self.kinds which tracks the various kinds of OCR that could be done. self.kinds should be in the form: {kind: region_name} where region_name is the name of the region to OCR for that kind. Will track ocr captured region results in form of list of dictionaries where these kinds are keys.

@staticmethod
def can_read_kind(self, frame: numpy.ndarray, kind: str) -> bool:
289    @staticmethod
290    def can_read_kind(self, frame: np.ndarray, kind: str) -> bool:
291        """
292        Checks if the frame has text for the given kind.
293
294        Args:
295            frame (np.ndarray): The frame to check.
296            kind (str): The kind of text to check for.
297        """
298        raise NotImplementedError

Checks if the frame has text for the given kind.

Arguments:
  • frame (np.ndarray): The frame to check.
  • kind (str): The kind of text to check for.
def reset(self, first=False):
300    def reset(self, first=False):
301        """
302        ocr_regions will track a list of the form List[Tuple[int, Dict[str, np.ndarray]]]
303        which is a list of (step_number, {kind: ocr_region}) dictionaries.
304        """
305        self.ocr_regions = []
306        self.steps = 0
307        self.prev_has_ocr = False

ocr_regions will track a list of the form List[Tuple[int, Dict[str, np.ndarray]]] which is a list of (step_number, {kind: ocr_region}) dictionaries.

def step( self, current_frame: numpy.ndarray, recent_frames: Optional[numpy.ndarray]):
309    def step(self, current_frame: np.ndarray, recent_frames: Optional[np.ndarray]):
310        all_frames = None
311        if recent_frames is not None:
312            all_frames = recent_frames  # Current frame is included in recent frames
313        else:
314            all_frames = np.array([current_frame])
315        ocr_dict = {}
316        # Aggregate results for all frames and separate per kind.
317        for kind in self.kinds.keys():
318            captured_frames = []
319            for frame in all_frames:
320                if self.can_read_kind(frame, kind):
321                    captured_frames.append(
322                        self.state_parser.capture_named_region(
323                            current_frame=frame, name=self.kinds[kind]
324                        )
325                    )
326            true_captured_frames = []  # remove duplicates for efficiency
327            # add the later frame in case of overlap detection. This prefers more text for cases where text comes as a stream.
328            if len(captured_frames) > 0:
329                for i in range(len(captured_frames) - 1):
330                    curr_frame = captured_frames[i]
331                    next_frame = captured_frames[i + 1]
332                    if np.abs(curr_frame - next_frame).mean() > EPSILON:
333                        true_captured_frames.append(curr_frame)
334                true_captured_frames.append(
335                    captured_frames[-1]
336                )  # always add the last frame.
337                ocr_dict[kind] = np.array(true_captured_frames)
338        if len(ocr_dict) > 0:
339            self.ocr_regions.append((self.steps, ocr_dict))
340            self.prev_has_ocr = True
341        else:
342            self.prev_has_ocr = False
343        self.steps += 1

Called each environment step to update metrics.

Arguments:
  • current_frame (np.ndarray): The current frame rendered by the emulator.
  • recent_frames (Optional[np.ndarray]): The stack of frames that were rendered during the last action. Shape is [n_frames, height, width, channels]. Can be None if rendering is disabled.
def report(self):
345    def report(self):
346        """
347        Reports just the previous step's OCR regions if any were found.
348        Returns:
349            dict: A dictionary containing the captured regions for focused OCR.
350        """
351        if self.prev_has_ocr:
352            return {
353                "ocr_regions": self.ocr_regions[-1][1],
354                "step": self.ocr_regions[-1][0],
355            }
356        else:
357            return {}

Reports just the previous step's OCR regions if any were found.

Returns:

dict: A dictionary containing the captured regions for focused OCR.

def report_final(self):
359    def report_final(self):
360        """
361        Reports all the OCR regions extracted in the episode.
362        """
363        return {"ocr_regions": self.ocr_regions}

Reports all the OCR regions extracted in the episode.

def close(self):
365    def close(self):
366        pass

Called when environment closes. Good for computing summary stats.

Step will not be called after this.

class SubGoal(abc.ABC):
369class SubGoal(ABC):
370    """
371    Abstract class representing a subgoal for tracking progress towards a test goal. These are intermediate states that must be achieved on the way to the final test goal.
372    By convention, the task goal state itself is *not* considered a subgoal, but rather the final goal that the subgoals lead towards.
373    """
374
375    NAME = "placeholder"
376    """ Name of the subgoal. """
377
378    def __init__(self):
379        if self.NAME == "placeholder":
380            log_error(
381                "Subclasses of SubGoal must set a unique NAME class variable.",
382            )
383        self.completed = False
384
385    def check_completed(self, frames: np.ndarray, parser: StateParser) -> bool:
386        """
387        Checks whether the subgoal has been completed based on the given frames and state parser.
388
389        Args:
390            frames (np.ndarray): The stack of frames to check for subgoal completion.
391            parser (StateParser): The state parser to use for checking subgoal completion.
392        Returns:
393            bool: True if the subgoal is completed, False otherwise.
394        """
395        for frame in frames:
396            if self._check_completed(frame, parser):
397                return True
398        return False
399
400    @abstractmethod
401    def _check_completed(self, frame: np.ndarray, parser: StateParser) -> bool:
402        """
403        Checks whether the subgoal has been completed based on a single frame and the state parser.
404
405        Args:
406            frames (np.ndarray): A single frame to check for subgoal completion.
407            parser (StateParser): The state parser to use for checking subgoal completion.
408        Returns:
409            bool: True if the subgoal is completed, False otherwise.
410        """
411        pass

Abstract class representing a subgoal for tracking progress towards a test goal. These are intermediate states that must be achieved on the way to the final test goal. By convention, the task goal state itself is not considered a subgoal, but rather the final goal that the subgoals lead towards.

NAME = 'placeholder'

Name of the subgoal.

completed
def check_completed( self, frames: numpy.ndarray, parser: gameboy_worlds.emulation.parser.StateParser) -> bool:
385    def check_completed(self, frames: np.ndarray, parser: StateParser) -> bool:
386        """
387        Checks whether the subgoal has been completed based on the given frames and state parser.
388
389        Args:
390            frames (np.ndarray): The stack of frames to check for subgoal completion.
391            parser (StateParser): The state parser to use for checking subgoal completion.
392        Returns:
393            bool: True if the subgoal is completed, False otherwise.
394        """
395        for frame in frames:
396            if self._check_completed(frame, parser):
397                return True
398        return False

Checks whether the subgoal has been completed based on the given frames and state parser.

Arguments:
  • frames (np.ndarray): The stack of frames to check for subgoal completion.
  • parser (StateParser): The state parser to use for checking subgoal completion.
Returns:

bool: True if the subgoal is completed, False otherwise.

class DummySubGoal(SubGoal):
414class DummySubGoal(SubGoal):
415    """
416    A dummy subgoal that is never completed. Useful for testing.
417    """
418
419    NAME = "dummy_subgoal"
420
421    def _check_completed(self, frame: np.ndarray, parser: StateParser) -> bool:
422        return False

A dummy subgoal that is never completed. Useful for testing.

NAME = 'dummy_subgoal'

Name of the subgoal.

Inherited Members
SubGoal
completed
check_completed
class RegionMatchSubGoal(SubGoal, abc.ABC):
425class RegionMatchSubGoal(SubGoal, ABC):
426    """
427    A subgoal that is completed if a specific region matches a target. Can be used to track subgoals that require specific dialogue boxes to appear, etc.
428    """
429
430    NAME = "placeholder"
431    _NAMED_REGION: str = None
432    _TARGET_NAME: str = None
433
434    def __init__(self):
435        super().__init__()
436        if self._NAMED_REGION is None or self._TARGET_NAME is None:
437            log_error(
438                "Subclasses of RegionMatchSubGoal must set _NAMED_REGION and _TARGET_NAME class variables.",
439            )
440
441    def _check_completed(self, frame: np.ndarray, parser: StateParser) -> bool:
442        matches = parser.named_region_matches_multi_target(
443            frame, self._NAMED_REGION, self._TARGET_NAME
444        )
445        return matches

A subgoal that is completed if a specific region matches a target. Can be used to track subgoals that require specific dialogue boxes to appear, etc.

NAME = 'placeholder'

Name of the subgoal.

Inherited Members
SubGoal
completed
check_completed
class SingleRegionMatchSubGoal(SubGoal, abc.ABC):
448class SingleRegionMatchSubGoal(SubGoal, ABC):
449    """
450    A subgoal that is completed if a specific single region matches its target.
451    """
452
453    NAME = "placeholder"
454    _NAMED_REGION: str = None
455
456    def __init__(self):
457        super().__init__()
458        if self._NAMED_REGION is None:
459            log_error(
460                "Subclasses of SingleRegionMatchSubGoal must set _NAMED_REGION class variable.",
461            )
462
463    def _check_completed(self, frame, parser):
464        matches = parser.named_region_matches_target(frame, self._NAMED_REGION)
465        return matches

A subgoal that is completed if a specific single region matches its target.

NAME = 'placeholder'

Name of the subgoal.

Inherited Members
SubGoal
completed
check_completed
class AnyRegionMatchSubGoal(SubGoal, abc.ABC):
468class AnyRegionMatchSubGoal(SubGoal, ABC):
469    """
470    A subgoal that is completed if any of a list of specific regions matches their targets.
471    """
472
473    NAME = "placeholder"
474    _NAMED_REGIONS: List[str] = None
475    _TARGET_NAMES: List[str] = None
476
477    def __init__(self):
478        super().__init__()
479        if (
480            self._NAMED_REGIONS is None
481            or self._TARGET_NAMES is None
482            or len(self._NAMED_REGIONS) != len(self._TARGET_NAMES)
483            or len(self._NAMED_REGIONS) == 0
484        ):
485            log_error(
486                "Subclasses of AnyRegionMatchSubGoal must set _NAMED_REGIONS and _TARGET_NAMES class variables, and they must be of the same length non zero.",
487            )
488
489    def _check_completed(self, frame: np.ndarray, parser: StateParser) -> bool:
490        for named_region, target_name in zip(self._NAMED_REGIONS, self._TARGET_NAMES):
491            matches = parser.named_region_matches_multi_target(
492                frame, named_region, target_name
493            )
494            if matches:
495                return True
496        return False

A subgoal that is completed if any of a list of specific regions matches their targets.

NAME = 'placeholder'

Name of the subgoal.

Inherited Members
SubGoal
completed
check_completed
class AnySingleRegionMatchSubGoal(SubGoal, abc.ABC):
499class AnySingleRegionMatchSubGoal(SubGoal, ABC):
500    """
501    A subgoal that is completed if any of a list of specific regions matches their targets, where each region only has one target.
502    """
503
504    NAME = "placeholder"
505    _NAMED_REGIONS: List[str] = None
506
507    def __init__(self):
508        super().__init__()
509        if self._NAMED_REGIONS is None or len(self._NAMED_REGIONS) == 0:
510            log_error(
511                "Subclasses of AnySingleRegionMatchSubGoal must set _NAMED_REGIONS class variable, and it must be non empty.",
512            )
513
514    def _check_completed(self, frame: np.ndarray, parser: StateParser) -> bool:
515        for named_region in self._NAMED_REGIONS:
516            matches = parser.named_region_matches_target(frame, named_region)
517            if matches:
518                return True
519        return False

A subgoal that is completed if any of a list of specific regions matches their targets, where each region only has one target.

NAME = 'placeholder'

Name of the subgoal.

Inherited Members
SubGoal
completed
check_completed
class SubGoalMetric(MetricGroup, abc.ABC):
522class SubGoalMetric(MetricGroup, ABC):
523    """
524    Tracks subgoal based progress towards a specific test goal.
525    Subgoals are always sequential, i.e. it is impossible to complete subgoal n+1 without completing subgoal n first.
526
527    Reports:
528    - `all`: A list of the names of all subgoals being tracked, regardless of completion status.
529    - `completed`: A list of the names of the subgoals that have been completed.
530
531    Final Reports:
532    - `reached_subgoals`: List of subgoals that were reached at any point during any episode.
533    """
534
535    NAME = "subgoals"
536    SUBGOALS: List[SubGoal] = []
537    """ List of SubGoal classes representing the subgoals to be tracked. These should be defined in child classes. """
538
539    def start(self):
540        if self.NAME != "subgoals":
541            log_error(
542                f"SubGoalMetric NAME must be 'subgoals', got '{self.NAME}'.",
543                self._parameters,
544            )
545        if len(self.SUBGOALS) == 0:
546            log_error(
547                "SubGoalMetric requires at least one subgoal to be defined in the SUBGOALS class variable.",
548                self._parameters,
549            )
550        self._subgoals: List[SubGoal] = []
551        """ List of SubGoal instances representing the subgoals being tracked. """
552        for subgoal_class in self.SUBGOALS:
553            subgoal_instance: SubGoal = subgoal_class()
554            self._subgoals.append(subgoal_instance)
555        self._reached_subgoals: Set[str] = set()
556        """ Set of subgoals that were reached at any point during any episode. """
557        super().start()
558
559    def close(self):
560        pass
561
562    def reset(self, first=False):
563        if not first:
564            for subgoal in self._subgoals:
565                if subgoal.completed:
566                    self._reached_subgoals.add(subgoal.NAME)
567        for subgoal in self._subgoals:
568            subgoal.completed = False
569
570    def step(self, current_frame: np.ndarray, recent_frames: Optional[np.ndarray]):
571        all_frames = None
572        if recent_frames is not None:
573            all_frames = recent_frames  # Current frame is included in recent frames
574        else:
575            all_frames = np.array([current_frame])
576        for subgoal in self._subgoals:
577            if not subgoal.completed:
578                completed = subgoal.check_completed(all_frames, self.state_parser)
579                subgoal.completed = completed
580
581    def report(self):
582        """
583        Reports the names of the subgoals being tracked and which ones have been completed.
584        Returns:
585            dict: A dictionary containing the list of all subgoals and the list of completed subgoals.
586        """
587        return {
588            "all": [subgoal.NAME for subgoal in self._subgoals],
589            "completed": [
590                subgoal.NAME for subgoal in self._subgoals if subgoal.completed
591            ],
592        }
593
594    def report_final(self):
595        """
596        Reports the names of the subgoals that were reached at any point during any episode.
597        Returns:
598            dict: A dictionary containing the list of reached subgoals.
599        """
600        return {"reached_subgoals": list(self._reached_subgoals)}

Tracks subgoal based progress towards a specific test goal. Subgoals are always sequential, i.e. it is impossible to complete subgoal n+1 without completing subgoal n first.

Reports:

  • all: A list of the names of all subgoals being tracked, regardless of completion status.
  • completed: A list of the names of the subgoals that have been completed.

Final Reports:

  • reached_subgoals: List of subgoals that were reached at any point during any episode.
NAME = 'subgoals'

Name of the MetricGroup.

SUBGOALS: List[SubGoal] = []

List of SubGoal classes representing the subgoals to be tracked. These should be defined in child classes.

def start(self):
539    def start(self):
540        if self.NAME != "subgoals":
541            log_error(
542                f"SubGoalMetric NAME must be 'subgoals', got '{self.NAME}'.",
543                self._parameters,
544            )
545        if len(self.SUBGOALS) == 0:
546            log_error(
547                "SubGoalMetric requires at least one subgoal to be defined in the SUBGOALS class variable.",
548                self._parameters,
549            )
550        self._subgoals: List[SubGoal] = []
551        """ List of SubGoal instances representing the subgoals being tracked. """
552        for subgoal_class in self.SUBGOALS:
553            subgoal_instance: SubGoal = subgoal_class()
554            self._subgoals.append(subgoal_instance)
555        self._reached_subgoals: Set[str] = set()
556        """ Set of subgoals that were reached at any point during any episode. """
557        super().start()

Called once when environment starts. All subclasses should call super() AFTER initializing their own variables. Only variables that will persist across episodes should be initialized here.

def close(self):
559    def close(self):
560        pass

Called when environment closes. Good for computing summary stats.

Step will not be called after this.

def reset(self, first=False):
562    def reset(self, first=False):
563        if not first:
564            for subgoal in self._subgoals:
565                if subgoal.completed:
566                    self._reached_subgoals.add(subgoal.NAME)
567        for subgoal in self._subgoals:
568            subgoal.completed = False

Called when environment resets.

Arguments:
  • first (bool): Whether this is the first reset of the environment. If True, might need to aggregate metrics into running final totals.
def step( self, current_frame: numpy.ndarray, recent_frames: Optional[numpy.ndarray]):
570    def step(self, current_frame: np.ndarray, recent_frames: Optional[np.ndarray]):
571        all_frames = None
572        if recent_frames is not None:
573            all_frames = recent_frames  # Current frame is included in recent frames
574        else:
575            all_frames = np.array([current_frame])
576        for subgoal in self._subgoals:
577            if not subgoal.completed:
578                completed = subgoal.check_completed(all_frames, self.state_parser)
579                subgoal.completed = completed

Called each environment step to update metrics.

Arguments:
  • current_frame (np.ndarray): The current frame rendered by the emulator.
  • recent_frames (Optional[np.ndarray]): The stack of frames that were rendered during the last action. Shape is [n_frames, height, width, channels]. Can be None if rendering is disabled.
def report(self):
581    def report(self):
582        """
583        Reports the names of the subgoals being tracked and which ones have been completed.
584        Returns:
585            dict: A dictionary containing the list of all subgoals and the list of completed subgoals.
586        """
587        return {
588            "all": [subgoal.NAME for subgoal in self._subgoals],
589            "completed": [
590                subgoal.NAME for subgoal in self._subgoals if subgoal.completed
591            ],
592        }

Reports the names of the subgoals being tracked and which ones have been completed.

Returns:

dict: A dictionary containing the list of all subgoals and the list of completed subgoals.

def report_final(self):
594    def report_final(self):
595        """
596        Reports the names of the subgoals that were reached at any point during any episode.
597        Returns:
598            dict: A dictionary containing the list of reached subgoals.
599        """
600        return {"reached_subgoals": list(self._reached_subgoals)}

Reports the names of the subgoals that were reached at any point during any episode.

Returns:

dict: A dictionary containing the list of reached subgoals.

class DummySubGoalMetric(SubGoalMetric):
603class DummySubGoalMetric(SubGoalMetric):
604    """
605    A dummy SubGoalMetric that tracks a single DummySubGoal. Useful for testing.
606    """
607
608    SUBGOALS = [DummySubGoal]

A dummy SubGoalMetric that tracks a single DummySubGoal. Useful for testing.

SUBGOALS = [<class 'DummySubGoal'>]

List of SubGoal classes representing the subgoals to be tracked. These should be defined in child classes.

def make_subgoal_metric_class( subgoals: List[Type[SubGoal]]) -> Type[SubGoalMetric]:
611def make_subgoal_metric_class(subgoals: List[Type[SubGoal]]) -> Type[SubGoalMetric]:
612    """
613    Factory function to create a SubGoalMetric class with the given subgoals and name.
614
615    Args:
616        subgoals (List[Type[SubGoal]]): The list of SubGoal classes to track.
617        name (str): The name of the SubGoalMetric class.
618
619    Returns:
620        Type[SubGoalMetric]: A new SubGoalMetric class with the specified subgoals and name.
621    """
622    if len(subgoals) == 0:
623        log_error("Must provide at least one subgoal to create a SubGoalMetric class.")
624
625    class CustomSubGoalMetric(SubGoalMetric):
626        SUBGOALS = subgoals
627
628    return CustomSubGoalMetric

Factory function to create a SubGoalMetric class with the given subgoals and name.

Arguments:
  • subgoals (List[Type[SubGoal]]): The list of SubGoal classes to track.
  • name (str): The name of the SubGoalMetric class.
Returns:

Type[SubGoalMetric]: A new SubGoalMetric class with the specified subgoals and name.

class TerminationTruncationMetric(MetricGroup, abc.ABC):
631class TerminationTruncationMetric(MetricGroup, ABC):
632    """
633    Tracks whether the environment was terminated or truncated.
634
635    Reports:
636    - `terminated`: Whether the environment was terminated.
637    - `truncated`: Whether the environment was truncated.
638
639    Final Reports:
640    - `episode_end_reason`: List of reasons for episode endings: "terminated", "truncated", or None (None will occur only if there is a bug that leads to a premature reset).
641    """
642
643    NAME = "termination_truncation"
644
645    def start(self):
646        super().start()
647        if self.NAME != "termination_truncation":
648            log_error(
649                f"TerminationTruncationMetric NAME must be 'termination_truncation', got '{self.NAME}'.",
650                self._parameters,
651            )
652        self.episode_end_reason = []
653        """ List of reasons for episode: termination or truncation or None (None will occur only if there is a bug that leads to a premature reset). """
654        self.terminated = False
655        """ Whether the environment was terminated. """
656        self.truncated = False
657        """ Whether the environment was truncated. """
658
659    def reset(self, first=False):
660        if not first:
661            if self.terminated:
662                self.episode_end_reason.append("terminated")
663            elif self.truncated:
664                self.episode_end_reason.append("truncated")
665            else:
666                self.episode_end_reason.append(None)
667        self.terminated = False
668        self.truncated = False
669
670    def close(self):
671        pass
672
673    @abstractmethod
674    def determine_truncated(
675        self, current_frame: np.ndarray, recent_frames: Optional[np.ndarray]
676    ) -> bool:
677        """
678        Determines whether the environment was truncated.
679
680        :param current_frame: The current frame rendered by the emulator.
681        :type current_frame: np.ndarray
682        :param recent_frames: The stack of frames that were rendered during the last action. Shape is [n_frames, height, width, channels]. Can be None if rendering is disabled.
683        :type recent_frames: Optional[np.ndarray]
684        :return: True if the environment was truncated, False otherwise.
685        :rtype: bool
686        """
687        pass
688
689    @abstractmethod
690    def determine_terminated(
691        self, current_frame: np.ndarray, recent_frames: Optional[np.ndarray]
692    ) -> bool:
693        """
694        Determines whether the environment was terminated.
695
696        :param current_frame: The current frame rendered by the emulator.
697        :type current_frame: np.ndarray
698        :param recent_frames: The stack of frames that were rendered during the last action. Shape is [n_frames, height, width, channels]. Can be None if rendering is disabled.
699        :type recent_frames: Optional[np.ndarray]
700        :return: True if the environment was terminated, False otherwise.
701        :rtype: bool
702        """
703        pass
704
705    def step(self, current_frame: np.ndarray, recent_frames: Optional[np.ndarray]):
706        """
707        Determines whether the environment was terminated or truncated.
708        """
709        if self.terminated or self.truncated:
710            return  # This should ideally not happen, because the environment should reset after termination or truncation.
711        self.truncated = self.determine_truncated(current_frame, recent_frames)
712        self.terminated = self.determine_terminated(current_frame, recent_frames)
713
714    def report(self):
715        """
716        Reports whether the environment was terminated or truncated.
717        Returns:
718            dict: A dictionary containing the termination and truncation status.
719        """
720        return {
721            "terminated": self.terminated,
722            "truncated": self.truncated,
723        }
724
725    def report_final(self):
726        """
727        Reports the reasons for episode endings.
728        Returns:
729            dict: A dictionary containing the list of episode end reasons.
730        """
731        return {"episode_end_reason": self.episode_end_reason}

Tracks whether the environment was terminated or truncated.

Reports:

  • terminated: Whether the environment was terminated.
  • truncated: Whether the environment was truncated.

Final Reports:

  • episode_end_reason: List of reasons for episode endings: "terminated", "truncated", or None (None will occur only if there is a bug that leads to a premature reset).
NAME = 'termination_truncation'

Name of the MetricGroup.

def start(self):
645    def start(self):
646        super().start()
647        if self.NAME != "termination_truncation":
648            log_error(
649                f"TerminationTruncationMetric NAME must be 'termination_truncation', got '{self.NAME}'.",
650                self._parameters,
651            )
652        self.episode_end_reason = []
653        """ List of reasons for episode: termination or truncation or None (None will occur only if there is a bug that leads to a premature reset). """
654        self.terminated = False
655        """ Whether the environment was terminated. """
656        self.truncated = False
657        """ Whether the environment was truncated. """

Called once when environment starts. All subclasses should call super() AFTER initializing their own variables. Only variables that will persist across episodes should be initialized here.

def reset(self, first=False):
659    def reset(self, first=False):
660        if not first:
661            if self.terminated:
662                self.episode_end_reason.append("terminated")
663            elif self.truncated:
664                self.episode_end_reason.append("truncated")
665            else:
666                self.episode_end_reason.append(None)
667        self.terminated = False
668        self.truncated = False

Called when environment resets.

Arguments:
  • first (bool): Whether this is the first reset of the environment. If True, might need to aggregate metrics into running final totals.
def close(self):
670    def close(self):
671        pass

Called when environment closes. Good for computing summary stats.

Step will not be called after this.

@abstractmethod
def determine_truncated( self, current_frame: numpy.ndarray, recent_frames: Optional[numpy.ndarray]) -> bool:
673    @abstractmethod
674    def determine_truncated(
675        self, current_frame: np.ndarray, recent_frames: Optional[np.ndarray]
676    ) -> bool:
677        """
678        Determines whether the environment was truncated.
679
680        :param current_frame: The current frame rendered by the emulator.
681        :type current_frame: np.ndarray
682        :param recent_frames: The stack of frames that were rendered during the last action. Shape is [n_frames, height, width, channels]. Can be None if rendering is disabled.
683        :type recent_frames: Optional[np.ndarray]
684        :return: True if the environment was truncated, False otherwise.
685        :rtype: bool
686        """
687        pass

Determines whether the environment was truncated.

Parameters
  • current_frame: The current frame rendered by the emulator.
  • recent_frames: The stack of frames that were rendered during the last action. Shape is [n_frames, height, width, channels]. Can be None if rendering is disabled.
Returns

True if the environment was truncated, False otherwise.

@abstractmethod
def determine_terminated( self, current_frame: numpy.ndarray, recent_frames: Optional[numpy.ndarray]) -> bool:
689    @abstractmethod
690    def determine_terminated(
691        self, current_frame: np.ndarray, recent_frames: Optional[np.ndarray]
692    ) -> bool:
693        """
694        Determines whether the environment was terminated.
695
696        :param current_frame: The current frame rendered by the emulator.
697        :type current_frame: np.ndarray
698        :param recent_frames: The stack of frames that were rendered during the last action. Shape is [n_frames, height, width, channels]. Can be None if rendering is disabled.
699        :type recent_frames: Optional[np.ndarray]
700        :return: True if the environment was terminated, False otherwise.
701        :rtype: bool
702        """
703        pass

Determines whether the environment was terminated.

Parameters
  • current_frame: The current frame rendered by the emulator.
  • recent_frames: The stack of frames that were rendered during the last action. Shape is [n_frames, height, width, channels]. Can be None if rendering is disabled.
Returns

True if the environment was terminated, False otherwise.

def step( self, current_frame: numpy.ndarray, recent_frames: Optional[numpy.ndarray]):
705    def step(self, current_frame: np.ndarray, recent_frames: Optional[np.ndarray]):
706        """
707        Determines whether the environment was terminated or truncated.
708        """
709        if self.terminated or self.truncated:
710            return  # This should ideally not happen, because the environment should reset after termination or truncation.
711        self.truncated = self.determine_truncated(current_frame, recent_frames)
712        self.terminated = self.determine_terminated(current_frame, recent_frames)

Determines whether the environment was terminated or truncated.

def report(self):
714    def report(self):
715        """
716        Reports whether the environment was terminated or truncated.
717        Returns:
718            dict: A dictionary containing the termination and truncation status.
719        """
720        return {
721            "terminated": self.terminated,
722            "truncated": self.truncated,
723        }

Reports whether the environment was terminated or truncated.

Returns:

dict: A dictionary containing the termination and truncation status.

def report_final(self):
725    def report_final(self):
726        """
727        Reports the reasons for episode endings.
728        Returns:
729            dict: A dictionary containing the list of episode end reasons.
730        """
731        return {"episode_end_reason": self.episode_end_reason}

Reports the reasons for episode endings.

Returns:

dict: A dictionary containing the list of episode end reasons.

class TerminationMetric(TerminationTruncationMetric, abc.ABC):
734class TerminationMetric(TerminationTruncationMetric, ABC):
735    def determine_truncated(
736        self, current_frame: np.ndarray, recent_frames: Optional[np.ndarray]
737    ) -> bool:
738        return False

Tracks whether the environment was terminated or truncated.

Reports:

  • terminated: Whether the environment was terminated.
  • truncated: Whether the environment was truncated.

Final Reports:

  • episode_end_reason: List of reasons for episode endings: "terminated", "truncated", or None (None will occur only if there is a bug that leads to a premature reset).
def determine_truncated( self, current_frame: numpy.ndarray, recent_frames: Optional[numpy.ndarray]) -> bool:
735    def determine_truncated(
736        self, current_frame: np.ndarray, recent_frames: Optional[np.ndarray]
737    ) -> bool:
738        return False

Determines whether the environment was truncated.

Parameters
  • current_frame: The current frame rendered by the emulator.
  • recent_frames: The stack of frames that were rendered during the last action. Shape is [n_frames, height, width, channels]. Can be None if rendering is disabled.
Returns

True if the environment was truncated, False otherwise.

class StateTracker:
741class StateTracker:
742    """
743    Tracks and provides API access to the game state / metrics over time and across episodes.
744    The most hassle-free way to read from the StateTracker is to use the `report()` and `report_final()` methods to get nested dictionaries of all metrics tracked.
745
746    **Example Usage:**
747
748    ```python
749    import numpy as np
750    from gameboy_worlds import get_pokemon_emulator
751    emulator = get_pokemon_emulator(variant="pokemon_red")
752
753    # We can access the StateTracker via the emulator
754    state_tracker = emulator.state_tracker
755
756    # Run a random action on the emulator
757    emulator.reset()
758    allowed_actions = list(LowLevelActions)
759    action = np.random.choice(allowed_actions)
760    _, _ = emulator.step(action) # also updates the StateTracker internally
761    # We can access the current episode metrics via the StateTracker
762    episode_metrics = state_tracker.report() # access all of them as a nested dict
763    specific_metric = state_tracker.get_episode_metric(("core", "steps")) # access specific metrics
764
765    # If we reset the emulator, the StateTracker will reset its inter-episode metrics as well
766    emulator.reset()
767    action = np.random.choice(allowed_actions)
768    _, _ = emulator.step(action)
769    emulator.close() # StateTracker will finalize its metrics internally
770    final_metrics = state_tracker.report_final() # access all of them as a nested dict
771    specific_final_metric = state_tracker.get_final_metric(("core", "average_steps_per_episode")) # access specific final metrics
772    ```
773    """
774
775    TERMINATION_TRUNCATION_METRIC: Type[TerminationTruncationMetric] = None
776    """ The TerminationTruncationMetric class to use for tracking termination and truncation. If None, no such metric will be tracked. """
777
778    SUBGOAL_METRIC: Type[SubGoalMetric] = None
779    """ The SubGoalMetric class to use for tracking subgoal progress. If None, no such metric will be tracked. """
780
781    def __init__(
782        self,
783        state_parser: StateParser,
784        parameters: dict,
785    ):
786        """
787        Initializes the StateTracker.
788        Args:
789            state_parser (StateParser): An instance of the StateParser to parse game state variables.
790            parameters (dict): A dictionary of parameters for configuration.
791        """
792        verify_parameters(parameters)
793        self.state_parser = state_parser
794        """ An instance of the StateParser to parse game state variables. """
795        self._parameters = parameters
796        self.start()
797        self.validate()
798        if self.metric_classes[0] != CoreMetrics:
799            log_error(
800                "First metric class must be CoreMetrics. Make sure to call `super().start()` first in child class overrides of `start()`.",
801                parameters,
802            )
803        self.metrics = {}
804        """ Dictionary to store MetricGroup instances. """
805        for metric_group_class in self.metric_classes:
806            metric_group_instance: MetricGroup = metric_group_class(
807                state_parser, parameters
808            )
809            self.metrics[metric_group_instance.NAME] = metric_group_instance
810        self.episode_metrics: Dict[str, Dict[str, Any]] = {}
811        """ Dictionary to store metrics running during episode. """
812        self.final_metrics: Dict[str, Dict[str, Any]] = {}
813
814    def start(self):
815        """
816        Sets up the metrics for the tracker by creating the list `self.metric_classes`
817
818        Child classes must FIRST call super().start() and THEN set up their own metric classes.
819        """
820        self.metric_classes: List[Type[MetricGroup]] = [CoreMetrics]
821        if self.TERMINATION_TRUNCATION_METRIC is not None:
822            if not issubclass(
823                self.TERMINATION_TRUNCATION_METRIC, TerminationTruncationMetric
824            ):
825                log_error(
826                    "TERMINATION_TRUNCATION_METRIC must be a subclass of TerminationTruncationMetric.",
827                    self._parameters,
828                )
829            self.metric_classes.append(self.TERMINATION_TRUNCATION_METRIC)
830        if self.SUBGOAL_METRIC is not None:
831            if not issubclass(self.SUBGOAL_METRIC, SubGoalMetric):
832                log_error(
833                    "SUBGOAL_METRIC must be a subclass of SubGoalMetric.",
834                    self._parameters,
835                )
836            self.metric_classes.append(self.SUBGOAL_METRIC)
837
838    def validate(self):
839        """
840        Is meant to be called once after initialization to ensure that the tracker is valid.
841        """
842        pass
843
844    def reset(self):
845        """
846        Is called once per environment reset to reset any tracked metrics.
847        """
848        for metric_group in self.metrics.values():
849            metric_group.reset()
850        self.step()
851
852    def step(self, recent_frames: Optional[np.ndarray] = None):
853        """
854        Is called once per environment step to update any tracked metrics.
855
856        Args:
857            recent_frames (Optional[np.ndarray]): The stack of frames that were rendered during the last action. Shape is [n_frames, height, width, channels]. Can be None if rendering is disabled.
858            epsilon (float, optional): The threshold for considering a frame change.
859        """
860        current_frame = None
861        if recent_frames is None:
862            current_frame = self.state_parser.get_current_frame()
863        else:
864            current_frame = recent_frames[-1]
865        self.episode_metrics = {}
866        for metric_group in self.metrics.values():
867            metric_group.step(current_frame, recent_frames)
868            self.episode_metrics[metric_group.NAME] = metric_group.report()
869
870    def close(self):
871        """
872        Is called once when the environment is closed to finalize any tracked metrics.
873        """
874        for metric_group in self.metrics.values():
875            metric_group.close()
876        self.final_metrics = {
877            name: mg.report_final() for name, mg in self.metrics.items()
878        }
879
880    def report(self) -> Dict[str, Dict[str, Any]]:
881        """
882        Returns the current episode metrics.
883
884        :return: A nested dictionary containing the current episode metrics.
885        :rtype: Dict[str, Dict[str, Any]]
886        """
887        return self.episode_metrics
888
889    def report_final(self) -> Dict[str, Dict[str, Any]]:
890        """
891        Returns the final metrics after environment close.
892
893        Returns:
894            Dict[str, Dict[str, Any]]: A nested dictionary containing the final metrics.
895        """
896        return self.final_metrics
897
898    def _get_specific_metric(self, metrics_dict, key: Tuple[str, str]):
899        if metrics_dict is None:
900            log_error("No metrics available. Have you called step() or close()?")
901        metric_group_name, metric_name = key
902        if metric_group_name not in metrics_dict:
903            log_error(
904                f"Metric group {metric_group_name} not found in metrics. Available groups: {list(metrics_dict.keys())}"
905            )
906        if metric_name not in metrics_dict[metric_group_name]:
907            log_error(
908                f"Metric {metric_name} not found in metric group {metric_group_name}. Available metrics: {list(metrics_dict[metric_group_name].keys())}"
909            )
910        return metrics_dict[metric_group_name][metric_name]
911
912    def get_episode_metric(self, key: Tuple[str, str]):
913        """
914        Returns the metrics for a specific episode and metric group.
915
916        Does not give final metrics at any point.
917
918        :param key: A tuple of the form (metric_group_name, metric_name).
919        :type key: Tuple[str, str]
920        :return: The requested metric value
921        :rtype: Any
922        """
923        return self._get_specific_metric(self.episode_metrics, key)
924
925    def get_final_metric(self, key: Tuple[str, str]):
926        """
927        Returns the final metrics for a specific metric group.
928
929        :param key: A tuple of the form (metric_group_name, metric_name).
930        :type key: Tuple[str, str]
931        :return: The requested final metric value
932        :rtype: Any
933        """
934        return self._get_specific_metric(self.final_metrics, key)
935
936    def __repr__(self) -> str:
937        metric_names = [mg.NAME for mg in self.metrics.values()]
938        return f"<StateTracker, metrics=({', '.join(metric_names)})>"

Tracks and provides API access to the game state / metrics over time and across episodes. The most hassle-free way to read from the StateTracker is to use the report() and report_final() methods to get nested dictionaries of all metrics tracked.

Example Usage:

import numpy as np
from gameboy_worlds import get_pokemon_emulator
emulator = get_pokemon_emulator(variant="pokemon_red")

# We can access the StateTracker via the emulator
state_tracker = emulator.state_tracker

# Run a random action on the emulator
emulator.reset()
allowed_actions = list(LowLevelActions)
action = np.random.choice(allowed_actions)
_, _ = emulator.step(action) # also updates the StateTracker internally
# We can access the current episode metrics via the StateTracker
episode_metrics = state_tracker.report() # access all of them as a nested dict
specific_metric = state_tracker.get_episode_metric(("core", "steps")) # access specific metrics

# If we reset the emulator, the StateTracker will reset its inter-episode metrics as well
emulator.reset()
action = np.random.choice(allowed_actions)
_, _ = emulator.step(action)
emulator.close() # StateTracker will finalize its metrics internally
final_metrics = state_tracker.report_final() # access all of them as a nested dict
specific_final_metric = state_tracker.get_final_metric(("core", "average_steps_per_episode")) # access specific final metrics
StateTracker( state_parser: gameboy_worlds.emulation.parser.StateParser, parameters: dict)
781    def __init__(
782        self,
783        state_parser: StateParser,
784        parameters: dict,
785    ):
786        """
787        Initializes the StateTracker.
788        Args:
789            state_parser (StateParser): An instance of the StateParser to parse game state variables.
790            parameters (dict): A dictionary of parameters for configuration.
791        """
792        verify_parameters(parameters)
793        self.state_parser = state_parser
794        """ An instance of the StateParser to parse game state variables. """
795        self._parameters = parameters
796        self.start()
797        self.validate()
798        if self.metric_classes[0] != CoreMetrics:
799            log_error(
800                "First metric class must be CoreMetrics. Make sure to call `super().start()` first in child class overrides of `start()`.",
801                parameters,
802            )
803        self.metrics = {}
804        """ Dictionary to store MetricGroup instances. """
805        for metric_group_class in self.metric_classes:
806            metric_group_instance: MetricGroup = metric_group_class(
807                state_parser, parameters
808            )
809            self.metrics[metric_group_instance.NAME] = metric_group_instance
810        self.episode_metrics: Dict[str, Dict[str, Any]] = {}
811        """ Dictionary to store metrics running during episode. """
812        self.final_metrics: Dict[str, Dict[str, Any]] = {}

Initializes the StateTracker.

Arguments:
  • state_parser (StateParser): An instance of the StateParser to parse game state variables.
  • parameters (dict): A dictionary of parameters for configuration.
TERMINATION_TRUNCATION_METRIC: Type[TerminationTruncationMetric] = None

The TerminationTruncationMetric class to use for tracking termination and truncation. If None, no such metric will be tracked.

SUBGOAL_METRIC: Type[SubGoalMetric] = None

The SubGoalMetric class to use for tracking subgoal progress. If None, no such metric will be tracked.

state_parser

An instance of the StateParser to parse game state variables.

metrics

Dictionary to store MetricGroup instances.

episode_metrics: Dict[str, Dict[str, Any]]

Dictionary to store metrics running during episode.

final_metrics: Dict[str, Dict[str, Any]]
def start(self):
814    def start(self):
815        """
816        Sets up the metrics for the tracker by creating the list `self.metric_classes`
817
818        Child classes must FIRST call super().start() and THEN set up their own metric classes.
819        """
820        self.metric_classes: List[Type[MetricGroup]] = [CoreMetrics]
821        if self.TERMINATION_TRUNCATION_METRIC is not None:
822            if not issubclass(
823                self.TERMINATION_TRUNCATION_METRIC, TerminationTruncationMetric
824            ):
825                log_error(
826                    "TERMINATION_TRUNCATION_METRIC must be a subclass of TerminationTruncationMetric.",
827                    self._parameters,
828                )
829            self.metric_classes.append(self.TERMINATION_TRUNCATION_METRIC)
830        if self.SUBGOAL_METRIC is not None:
831            if not issubclass(self.SUBGOAL_METRIC, SubGoalMetric):
832                log_error(
833                    "SUBGOAL_METRIC must be a subclass of SubGoalMetric.",
834                    self._parameters,
835                )
836            self.metric_classes.append(self.SUBGOAL_METRIC)

Sets up the metrics for the tracker by creating the list self.metric_classes

Child classes must FIRST call super().start() and THEN set up their own metric classes.

def validate(self):
838    def validate(self):
839        """
840        Is meant to be called once after initialization to ensure that the tracker is valid.
841        """
842        pass

Is meant to be called once after initialization to ensure that the tracker is valid.

def reset(self):
844    def reset(self):
845        """
846        Is called once per environment reset to reset any tracked metrics.
847        """
848        for metric_group in self.metrics.values():
849            metric_group.reset()
850        self.step()

Is called once per environment reset to reset any tracked metrics.

def step(self, recent_frames: Optional[numpy.ndarray] = None):
852    def step(self, recent_frames: Optional[np.ndarray] = None):
853        """
854        Is called once per environment step to update any tracked metrics.
855
856        Args:
857            recent_frames (Optional[np.ndarray]): The stack of frames that were rendered during the last action. Shape is [n_frames, height, width, channels]. Can be None if rendering is disabled.
858            epsilon (float, optional): The threshold for considering a frame change.
859        """
860        current_frame = None
861        if recent_frames is None:
862            current_frame = self.state_parser.get_current_frame()
863        else:
864            current_frame = recent_frames[-1]
865        self.episode_metrics = {}
866        for metric_group in self.metrics.values():
867            metric_group.step(current_frame, recent_frames)
868            self.episode_metrics[metric_group.NAME] = metric_group.report()

Is called once per environment step to update any tracked metrics.

Arguments:
  • recent_frames (Optional[np.ndarray]): The stack of frames that were rendered during the last action. Shape is [n_frames, height, width, channels]. Can be None if rendering is disabled.
  • epsilon (float, optional): The threshold for considering a frame change.
def close(self):
870    def close(self):
871        """
872        Is called once when the environment is closed to finalize any tracked metrics.
873        """
874        for metric_group in self.metrics.values():
875            metric_group.close()
876        self.final_metrics = {
877            name: mg.report_final() for name, mg in self.metrics.items()
878        }

Is called once when the environment is closed to finalize any tracked metrics.

def report(self) -> Dict[str, Dict[str, Any]]:
880    def report(self) -> Dict[str, Dict[str, Any]]:
881        """
882        Returns the current episode metrics.
883
884        :return: A nested dictionary containing the current episode metrics.
885        :rtype: Dict[str, Dict[str, Any]]
886        """
887        return self.episode_metrics

Returns the current episode metrics.

Returns

A nested dictionary containing the current episode metrics.

def report_final(self) -> Dict[str, Dict[str, Any]]:
889    def report_final(self) -> Dict[str, Dict[str, Any]]:
890        """
891        Returns the final metrics after environment close.
892
893        Returns:
894            Dict[str, Dict[str, Any]]: A nested dictionary containing the final metrics.
895        """
896        return self.final_metrics

Returns the final metrics after environment close.

Returns:

Dict[str, Dict[str, Any]]: A nested dictionary containing the final metrics.

def get_episode_metric(self, key: Tuple[str, str]):
912    def get_episode_metric(self, key: Tuple[str, str]):
913        """
914        Returns the metrics for a specific episode and metric group.
915
916        Does not give final metrics at any point.
917
918        :param key: A tuple of the form (metric_group_name, metric_name).
919        :type key: Tuple[str, str]
920        :return: The requested metric value
921        :rtype: Any
922        """
923        return self._get_specific_metric(self.episode_metrics, key)

Returns the metrics for a specific episode and metric group.

Does not give final metrics at any point.

Parameters
  • key: A tuple of the form (metric_group_name, metric_name).
Returns

The requested metric value

def get_final_metric(self, key: Tuple[str, str]):
925    def get_final_metric(self, key: Tuple[str, str]):
926        """
927        Returns the final metrics for a specific metric group.
928
929        :param key: A tuple of the form (metric_group_name, metric_name).
930        :type key: Tuple[str, str]
931        :return: The requested final metric value
932        :rtype: Any
933        """
934        return self._get_specific_metric(self.final_metrics, key)

Returns the final metrics for a specific metric group.

Parameters
  • key: A tuple of the form (metric_group_name, metric_name).
Returns

The requested final metric value

class TestTrackerMixin:
941class TestTrackerMixin:
942    """
943    Mixin class for testing trackers.
944    Ensures that exactly one of the tracked metrics is a TerminationTruncationMetric.
945    """
946
947    def validate(self):
948        if not hasattr(self, "_parameters"):
949            log_error("Parameters have not been set yet.")
950        if self.TERMINATION_TRUNCATION_METRIC is None:
951            log_error(
952                "TestTrackerMixin requires a TerminationTruncationMetric to be set as TERMINATION_TRUNCATION_METRIC.",
953                self._parameters,
954            )
955        if self.SUBGOAL_METRIC is None:
956            log_error(
957                "TestTrackerMixin requires a SubGoalMetric to be set as SUBGOAL_METRIC.",
958                self._parameters,
959            )

Mixin class for testing trackers. Ensures that exactly one of the tracked metrics is a TerminationTruncationMetric.

def validate(self):
947    def validate(self):
948        if not hasattr(self, "_parameters"):
949            log_error("Parameters have not been set yet.")
950        if self.TERMINATION_TRUNCATION_METRIC is None:
951            log_error(
952                "TestTrackerMixin requires a TerminationTruncationMetric to be set as TERMINATION_TRUNCATION_METRIC.",
953                self._parameters,
954            )
955        if self.SUBGOAL_METRIC is None:
956            log_error(
957                "TestTrackerMixin requires a SubGoalMetric to be set as SUBGOAL_METRIC.",
958                self._parameters,
959            )
class RegionMatchTruncationMetric(TerminationTruncationMetric, abc.ABC):
962class RegionMatchTruncationMetric(TerminationTruncationMetric, ABC):
963    """
964    Truncates the episode if a specific region matches a target.
965    Can be used to truncate episodes when specific dialogue boxes appear, etc.
966    """
967
968    _TRUNCATION_NAMED_REGION = None
969    _TRUNCATION_TARGET_NAME = None
970
971    def determine_truncated(self, current_frame, recent_frames):
972        if (
973            self._TRUNCATION_NAMED_REGION is None
974            or self._TRUNCATION_TARGET_NAME is None
975        ):
976            log_error(
977                "Must set _TRUNCATION_NAMED_REGION and _TRUNCATION_TARGET_NAME.",
978                self._parameters,
979            )
980        all_frames = [current_frame]
981        if recent_frames is not None:
982            all_frames = recent_frames
983        for frame in all_frames:
984            matches = self.state_parser.named_region_matches_multi_target(
985                frame,
986                self._TRUNCATION_NAMED_REGION,
987                self._TRUNCATION_TARGET_NAME,
988            )
989            if matches:
990                return True
991        return False

Truncates the episode if a specific region matches a target. Can be used to truncate episodes when specific dialogue boxes appear, etc.

def determine_truncated(self, current_frame, recent_frames):
971    def determine_truncated(self, current_frame, recent_frames):
972        if (
973            self._TRUNCATION_NAMED_REGION is None
974            or self._TRUNCATION_TARGET_NAME is None
975        ):
976            log_error(
977                "Must set _TRUNCATION_NAMED_REGION and _TRUNCATION_TARGET_NAME.",
978                self._parameters,
979            )
980        all_frames = [current_frame]
981        if recent_frames is not None:
982            all_frames = recent_frames
983        for frame in all_frames:
984            matches = self.state_parser.named_region_matches_multi_target(
985                frame,
986                self._TRUNCATION_NAMED_REGION,
987                self._TRUNCATION_TARGET_NAME,
988            )
989            if matches:
990                return True
991        return False

Determines whether the environment was truncated.

Parameters
  • current_frame: The current frame rendered by the emulator.
  • recent_frames: The stack of frames that were rendered during the last action. Shape is [n_frames, height, width, channels]. Can be None if rendering is disabled.
Returns

True if the environment was truncated, False otherwise.

class RegionMatchTerminationMetric(TerminationTruncationMetric, abc.ABC):
 994class RegionMatchTerminationMetric(TerminationTruncationMetric, ABC):
 995    """
 996    Terminates the episode if a specific region matches a target.
 997    Can be used to terminate episodes when specific dialogue boxes appear, etc.
 998    """
 999
1000    _TERMINATION_NAMED_REGION = None
1001    _TERMINATION_TARGET_NAME = None
1002
1003    def determine_terminated(self, current_frame, recent_frames):
1004        if (
1005            self._TERMINATION_NAMED_REGION is None
1006            or self._TERMINATION_TARGET_NAME is None
1007        ):
1008            log_error(
1009                "Must set _TERMINATION_NAMED_REGION and _TERMINATION_TARGET_NAME.",
1010                self._parameters,
1011            )
1012        all_frames = [current_frame]
1013        if recent_frames is not None:
1014            all_frames = recent_frames
1015        for frame in all_frames:
1016            matches = self.state_parser.named_region_matches_multi_target(
1017                frame,
1018                self._TERMINATION_NAMED_REGION,
1019                self._TERMINATION_TARGET_NAME,
1020            )
1021            if matches:
1022                return True
1023        return False

Terminates the episode if a specific region matches a target. Can be used to terminate episodes when specific dialogue boxes appear, etc.

def determine_terminated(self, current_frame, recent_frames):
1003    def determine_terminated(self, current_frame, recent_frames):
1004        if (
1005            self._TERMINATION_NAMED_REGION is None
1006            or self._TERMINATION_TARGET_NAME is None
1007        ):
1008            log_error(
1009                "Must set _TERMINATION_NAMED_REGION and _TERMINATION_TARGET_NAME.",
1010                self._parameters,
1011            )
1012        all_frames = [current_frame]
1013        if recent_frames is not None:
1014            all_frames = recent_frames
1015        for frame in all_frames:
1016            matches = self.state_parser.named_region_matches_multi_target(
1017                frame,
1018                self._TERMINATION_NAMED_REGION,
1019                self._TERMINATION_TARGET_NAME,
1020            )
1021            if matches:
1022                return True
1023        return False

Determines whether the environment was terminated.

Parameters
  • current_frame: The current frame rendered by the emulator.
  • recent_frames: The stack of frames that were rendered during the last action. Shape is [n_frames, height, width, channels]. Can be None if rendering is disabled.
Returns

True if the environment was terminated, False otherwise.

class RegionMatchTerminationOnlyMetric(TerminationMetric, abc.ABC):
1026class RegionMatchTerminationOnlyMetric(TerminationMetric, ABC):
1027    """
1028    RegionMatchTerminationMetric with no truncation.
1029    No truncation.
1030    """
1031
1032    _TERMINATION_NAMED_REGION = None
1033    _TERMINATION_TARGET_NAME = None
1034
1035    def determine_terminated(self, current_frame, recent_frames):
1036        if (
1037            self._TERMINATION_NAMED_REGION is None
1038            or self._TERMINATION_TARGET_NAME is None
1039        ):
1040            log_error(
1041                "Must set _TERMINATION_NAMED_REGION and _TERMINATION_TARGET_NAME.",
1042                self._parameters,
1043            )
1044        all_frames = [current_frame]
1045        if recent_frames is not None:
1046            all_frames = recent_frames
1047        for frame in all_frames:
1048            matches = self.state_parser.named_region_matches_multi_target(
1049                frame,
1050                self._TERMINATION_NAMED_REGION,
1051                self._TERMINATION_TARGET_NAME,
1052            )
1053            if matches:
1054                return True
1055        return False

RegionMatchTerminationMetric with no truncation. No truncation.

def determine_terminated(self, current_frame, recent_frames):
1035    def determine_terminated(self, current_frame, recent_frames):
1036        if (
1037            self._TERMINATION_NAMED_REGION is None
1038            or self._TERMINATION_TARGET_NAME is None
1039        ):
1040            log_error(
1041                "Must set _TERMINATION_NAMED_REGION and _TERMINATION_TARGET_NAME.",
1042                self._parameters,
1043            )
1044        all_frames = [current_frame]
1045        if recent_frames is not None:
1046            all_frames = recent_frames
1047        for frame in all_frames:
1048            matches = self.state_parser.named_region_matches_multi_target(
1049                frame,
1050                self._TERMINATION_NAMED_REGION,
1051                self._TERMINATION_TARGET_NAME,
1052            )
1053            if matches:
1054                return True
1055        return False

Determines whether the environment was terminated.

Parameters
  • current_frame: The current frame rendered by the emulator.
  • recent_frames: The stack of frames that were rendered during the last action. Shape is [n_frames, height, width, channels]. Can be None if rendering is disabled.
Returns

True if the environment was terminated, False otherwise.

class AnyRegionMatchTerminationMetric(TerminationMetric, abc.ABC):
1057class AnyRegionMatchTerminationMetric(TerminationMetric, ABC):
1058    """
1059    Terminates the episode if any of a list of specific regions matches their targets.
1060    No truncation.
1061    """
1062
1063    _NAMED_REGIONS: List[str] = None
1064    _TARGET_NAMES: List[str] = None
1065
1066    def __init__(self):
1067        super().__init__()
1068        if (
1069            self._NAMED_REGIONS is None
1070            or self._TARGET_NAMES is None
1071            or len(self._NAMED_REGIONS) != len(self._TARGET_NAMES)
1072            or len(self._NAMED_REGIONS) == 0
1073        ):
1074            log_error(
1075                "Subclasses of AnyRegionMatchTerminationMetric must set _NAMED_REGIONS and _TARGET_NAMES class variables, and they must be of the same length non zero.",
1076            )
1077
1078    def determine_terminated(self, current_frame, recent_frames):
1079        all_frames = [current_frame]
1080        if recent_frames is not None:
1081            all_frames = recent_frames
1082        for frame in all_frames:
1083            for named_region, target_name in zip(self._NAMED_REGIONS, self._TARGET_NAMES):
1084                if self.state_parser.named_region_matches_multi_target(
1085                    frame, named_region, target_name
1086                ):
1087                    return True
1088        return False

Terminates the episode if any of a list of specific regions matches their targets. No truncation.

def determine_terminated(self, current_frame, recent_frames):
1078    def determine_terminated(self, current_frame, recent_frames):
1079        all_frames = [current_frame]
1080        if recent_frames is not None:
1081            all_frames = recent_frames
1082        for frame in all_frames:
1083            for named_region, target_name in zip(self._NAMED_REGIONS, self._TARGET_NAMES):
1084                if self.state_parser.named_region_matches_multi_target(
1085                    frame, named_region, target_name
1086                ):
1087                    return True
1088        return False

Determines whether the environment was terminated.

Parameters
  • current_frame: The current frame rendered by the emulator.
  • recent_frames: The stack of frames that were rendered during the last action. Shape is [n_frames, height, width, channels]. Can be None if rendering is disabled.
Returns

True if the environment was terminated, False otherwise.

class RegionChangedTerminationMetric(TerminationTruncationMetric, abc.ABC):
1091class RegionChangedTerminationMetric(TerminationTruncationMetric, ABC):
1092    """
1093    Terminates the episode if a specific named region changes significantly
1094    from its appearance at the start of the episode (on reset).
1095
1096    Useful for detecting pickups, stat changes, or any event that alters a
1097    HUD region without having a fixed reference capture.
1098
1099    Subclass and set:
1100        _CHANGED_NAMED_REGION: name of the NamedScreenRegion to monitor
1101        _CHANGE_MAE_THRESHOLD: MAE threshold above which the region is considered changed (default 10)
1102    """
1103
1104    _CHANGED_NAMED_REGION = None
1105    _CHANGE_MAE_THRESHOLD = 10
1106
1107    def reset(self, first=False):
1108        super().reset(first=first)
1109        self._region_baseline = None
1110
1111    def determine_truncated(self, current_frame, recent_frames):
1112        return False
1113
1114    def determine_terminated(self, current_frame, recent_frames):
1115        if self._CHANGED_NAMED_REGION is None:
1116            log_error("Must set _CHANGED_NAMED_REGION.", self._parameters)
1117        cropped = self.state_parser.capture_named_region(
1118            current_frame, self._CHANGED_NAMED_REGION
1119        )
1120        if self._region_baseline is None:
1121            self._region_baseline = cropped.copy()
1122            return False
1123        mae = np.abs(cropped.astype(float) - self._region_baseline.astype(float)).mean()
1124        return mae > self._CHANGE_MAE_THRESHOLD

Terminates the episode if a specific named region changes significantly from its appearance at the start of the episode (on reset).

Useful for detecting pickups, stat changes, or any event that alters a HUD region without having a fixed reference capture.

Subclass and set:

_CHANGED_NAMED_REGION: name of the NamedScreenRegion to monitor _CHANGE_MAE_THRESHOLD: MAE threshold above which the region is considered changed (default 10)

def reset(self, first=False):
1107    def reset(self, first=False):
1108        super().reset(first=first)
1109        self._region_baseline = None

Called when environment resets.

Arguments:
  • first (bool): Whether this is the first reset of the environment. If True, might need to aggregate metrics into running final totals.
def determine_truncated(self, current_frame, recent_frames):
1111    def determine_truncated(self, current_frame, recent_frames):
1112        return False

Determines whether the environment was truncated.

Parameters
  • current_frame: The current frame rendered by the emulator.
  • recent_frames: The stack of frames that were rendered during the last action. Shape is [n_frames, height, width, channels]. Can be None if rendering is disabled.
Returns

True if the environment was truncated, False otherwise.

def determine_terminated(self, current_frame, recent_frames):
1114    def determine_terminated(self, current_frame, recent_frames):
1115        if self._CHANGED_NAMED_REGION is None:
1116            log_error("Must set _CHANGED_NAMED_REGION.", self._parameters)
1117        cropped = self.state_parser.capture_named_region(
1118            current_frame, self._CHANGED_NAMED_REGION
1119        )
1120        if self._region_baseline is None:
1121            self._region_baseline = cropped.copy()
1122            return False
1123        mae = np.abs(cropped.astype(float) - self._region_baseline.astype(float)).mean()
1124        return mae > self._CHANGE_MAE_THRESHOLD

Determines whether the environment was terminated.

Parameters
  • current_frame: The current frame rendered by the emulator.
  • recent_frames: The stack of frames that were rendered during the last action. Shape is [n_frames, height, width, channels]. Can be None if rendering is disabled.
Returns

True if the environment was terminated, False otherwise.