diff --git a/docs/tutorial.md b/docs/tutorial.md index d94cb5c0..7ea3b91b 100644 --- a/docs/tutorial.md +++ b/docs/tutorial.md @@ -180,7 +180,7 @@ The best way to create a masked image is to set your capture region as the entir ### Reset Image -You can have one (and only one) image with the keyword `reset` in its name. AutoSplit will press the reset button when it finds this image. This image will only be used for resets and it will not be tied to any split. You can set a threshold and pause time for it. The pause time is the amount of seconds AutoSplit will wait before checking for the Reset Image once the run starts. For example: `Reset_(0.95)_[10].png`. +You can have one or more images with the keyword `reset` in their name. AutoSplit will press the reset button when it matches any of these images. These images will only be used for resets and they will not be tied to any split. You can set a threshold and pause time for each of them. The pause time is the amount of seconds AutoSplit will wait before checking for that Reset Image once the run starts. For example: `Reset_(0.95)_[10].png`. When there is more than one Reset Image, the comparison table shows the one closest to its threshold. ### Start Image diff --git a/src/AutoSplit.py b/src/AutoSplit.py index f8d8610f..09d1f618 100755 --- a/src/AutoSplit.py +++ b/src/AutoSplit.py @@ -19,12 +19,15 @@ # (PySide6.support.signature.* vs shibokensupport.*). # - numpy: its self-check raises a bogus version conflict when imported # through shiboken6's patched __import__. + # - Levenshtein: its lazy "import rapidfuzz.distance.X as _X" statements recurse + # infinitely (RecursionError) when reified through shiboken6's patched __import__. _EAGER_INTERNALS = ( frozenset({ "PySide6", "shiboken6", "shibokensupport", "numpy", + "Levenshtein", }) | sys.stdlib_module_names ) @@ -226,7 +229,7 @@ def __init__(self): # noqa: PLR0915 self.split_below_threshold = False self.run_start_time = 0.0 self.start_image: AutoSplitImage | None = None - self.reset_image: AutoSplitImage | None = None + self.reset_images: list[AutoSplitImage] = [] self.split_images: list[AutoSplitImage] = [] self.split_image: AutoSplitImage | None = None self.update_auto_control: AutoControlledThread | None = None @@ -663,11 +666,11 @@ def __check_fps(self): self.fps_value_label.clear() return - images = self.split_images - if self.start_image: - images.append(self.start_image) - if self.reset_image: - images.append(self.reset_image) + images = [ + *self.split_images, + *self.reset_images, + *([self.start_image] if self.start_image is not None else []), + ] # run X iterations of screenshotting capture region + comparison + displaying. t0 = time() @@ -988,7 +991,7 @@ def __pause_loop(self, stop_time: float, message: str): # This is done so that it can detect if user hit split/undo split while paused/delayed. pause_split_image_number = self.split_image_number while True: - # Calculate similarity for Reset Image + # Calculate similarity for Reset Images if self.__reset_if_should(self.__get_capture_for_comparison()): return True @@ -1092,31 +1095,45 @@ def __get_capture_for_comparison(self): def __reset_if_should(self, capture: MatLike | None): """Checks if we should reset, resets if it's the case, and returns the result.""" - if self.reset_image: + if self.reset_images: if self.settings_dict["enable_auto_reset"]: - similarity = self.reset_image.compare_with_capture(self, capture) - threshold = self.reset_image.get_similarity_threshold(self) - - pause_times = [self.reset_image.get_pause_time(self)] - if self.start_image: - pause_times.append(self.start_image.get_pause_time(self)) - paused = time() - self.run_start_time <= max(pause_times) - if paused: - should_reset = False - self.table_reset_image_live_label.setText("paused") - else: - should_reset = similarity >= threshold + elapsed_time = time() - self.run_start_time + start_pause_time = self.start_image.get_pause_time(self) if self.start_image else 0 + active_reset_images = [ + reset_image + for reset_image in self.reset_images + if elapsed_time > max(reset_image.get_pause_time(self), start_pause_time) + ] + if active_reset_images: + # Display the Reset Image that is the closest to its threshold + similarity, threshold = max( + ( + ( + reset_image.compare_with_capture(self, capture), + reset_image.get_similarity_threshold(self), + ) + for reset_image in active_reset_images + ), + key=lambda similarity_and_threshold: ( + similarity_and_threshold[0] - similarity_and_threshold[1] + ), + ) self.reset_highest_similarity = max(similarity, self.reset_highest_similarity) self.table_reset_image_highest_label.setText( decimal(self.reset_highest_similarity) ) self.table_reset_image_live_label.setText(decimal(similarity)) - - self.table_reset_image_threshold_label.setText(decimal(threshold)) - - if should_reset: - send_command(self, "reset") - self.reset() + self.table_reset_image_threshold_label.setText(decimal(threshold)) + if similarity >= threshold: + send_command(self, "reset") + self.reset() + else: + self.table_reset_image_live_label.setText("paused") + self.table_reset_image_threshold_label.setText( + decimal( + min(image.get_similarity_threshold(self) for image in self.reset_images) + ) + ) else: self.table_reset_image_live_label.setText("disabled") else: diff --git a/src/menu_bar.py b/src/menu_bar.py index 052aba6f..171d48e1 100644 --- a/src/menu_bar.py +++ b/src/menu_bar.py @@ -224,8 +224,13 @@ def __update_default_threshold(self, value: Any): else "-" ) self._autosplit_ref.table_reset_image_threshold_label.setText( - decimal(self._autosplit_ref.reset_image.get_similarity_threshold(self._autosplit_ref)) - if self._autosplit_ref.reset_image + decimal( + min( + reset_image.get_similarity_threshold(self._autosplit_ref) + for reset_image in self._autosplit_ref.reset_images + ) + ) + if self._autosplit_ref.reset_images else "-" ) diff --git a/src/split_parser.py b/src/split_parser.py index b84f76cc..6b20555a 100644 --- a/src/split_parser.py +++ b/src/split_parser.py @@ -7,12 +7,12 @@ from enum import IntEnum, auto from functools import partial from stat import UF_HIDDEN -from typing import TYPE_CHECKING, TypeVar +from typing import TYPE_CHECKING, Literal, TypeVar, overload import numpy as np import error_messages -from AutoSplitImage import RESET_KEYWORD, START_KEYWORD, AutoSplitImage, ImageType +from AutoSplitImage import START_KEYWORD, AutoSplitImage, ImageType from utils import ALPHA_CHANNEL_COUNT, MAXBYTE, ColorChannel, ImageShape, is_valid_image if sys.platform == "win32": @@ -234,13 +234,36 @@ def flags_from_filename(filename: str): return flags -def __pop_image_type(split_image: list[AutoSplitImage], image_type: ImageType): - for image in split_image: - if image.image_type == image_type: - split_image.remove(image) - return image - - return None +@overload +def __pop_image_type( + split_images: list[AutoSplitImage], image_type: Literal[ImageType.START] +) -> AutoSplitImage | None: ... +@overload +def __pop_image_type( + split_images: list[AutoSplitImage], image_type: Literal[ImageType.SPLIT, ImageType.RESET] +) -> list[AutoSplitImage]: ... +def __pop_image_type(split_images: list[AutoSplitImage], image_type: ImageType): + if image_type == ImageType.START: + # Only one Start Image is allowed. + # Any other is left in the list to be reported during validation. + for index, image in enumerate(split_images): + if image.image_type == image_type: + return split_images.pop(index) + return None + + images: list[AutoSplitImage] = [] + remaining_images: list[AutoSplitImage] = [] + for image in split_images: + (images if image.image_type == image_type else remaining_images).append(image) + split_images[:] = remaining_images + return images + + +def __set_reset_images(autosplit: AutoSplit, reset_images: list[AutoSplitImage]): + autosplit.reset_images = reset_images + autosplit.table_reset_image_label.setText( + f"Reset Images ({len(reset_images)})" if len(reset_images) > 1 else "Reset Image" + ) def validate_before_parsing(autosplit: AutoSplit, *, show_error: bool = True): @@ -298,7 +321,7 @@ def parse_and_validate_images(autosplit: AutoSplit): all_images = __get_images_from_directory(autosplit.settings_dict["split_image_directory"]) # Find non-split images and then remove them from the list start_image = __pop_image_type(all_images, ImageType.START) - reset_image = __pop_image_type(all_images, ImageType.RESET) + reset_images = __pop_image_type(all_images, ImageType.RESET) split_images = all_images error_message: Callable[[], object] | None = None @@ -318,7 +341,7 @@ def parse_and_validate_images(autosplit: AutoSplit): # If there is no reset hotkey set but a Reset Image is present, # and is not auto controlled, throw an error. elif ( - reset_image + reset_images and not autosplit.settings_dict["reset_hotkey"] and not autosplit.is_auto_controlled ): @@ -342,25 +365,20 @@ def parse_and_validate_images(autosplit: AutoSplit): error_message = error_messages.pause_hotkey break - # Check that there's only one Reset Image - if image.image_type == ImageType.RESET: - error_message = lambda: error_messages.multiple_keyword_images(RESET_KEYWORD) # noqa: E731 - break - # Check that there's only one Start Image if image.image_type == ImageType.START: - error_message = lambda: error_messages.multiple_keyword_images(START_KEYWORD) # noqa: E731 + error_message = partial(error_messages.multiple_keyword_images, START_KEYWORD) break if error_message: autosplit.start_image = None - autosplit.reset_image = None + __set_reset_images(autosplit, []) autosplit.split_images = [] autosplit.gui_changes_on_reset() error_message() return False autosplit.start_image = start_image - autosplit.reset_image = reset_image + __set_reset_images(autosplit, reset_images) autosplit.split_images = split_images return True